Improve authentication recovery and mobile gameplay

This commit is contained in:
2026-10-07 11:10:29 +05:00
parent 233e4d57f0
commit ce26e6fdbc
22 changed files with 763 additions and 97 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -284,3 +284,10 @@
gap: 8px; gap: 8px;
} }
} }
.legacy-password-dialog { width: min(540px, calc(100vw - 24px)); }
.legacy-password-dialog > svg { color: var(--green); }
.legacy-password-dialog h2 { margin-top: 16px; }
.legacy-password-dialog .field input { width: 100%; font: 14px monospace; padding: 12px; background: var(--bg); color: var(--text); border: 1px solid var(--line); }
.legacy-password-dialog > .text-link { margin-top: 18px; }
.password-saved { display: flex; gap: 10px; align-items: flex-start; margin: 20px 0; }
.password-saved input { flex-shrink: 0; margin-top: 5px; accent-color: var(--green); }

View File

@@ -11,11 +11,13 @@ export async function GET(request: NextRequest) {
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token"); const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
const type = request.nextUrl.searchParams.get("type"); const type = request.nextUrl.searchParams.get("type");
const next = authDestination(request.nextUrl.searchParams.get("next")); const next = authDestination(request.nextUrl.searchParams.get("next"));
if (type === "magiclink")
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
if ( if (
code || code ||
legacyToken || legacyToken ||
(tokenHash && (tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink")) (type === "email" || type === "signup" || type === "recovery"))
) { ) {
const client = await authClient(); const client = await authClient();
let { data, error } = code let { data, error } = code
@@ -24,7 +26,7 @@ export async function GET(request: NextRequest) {
? await client.auth.exchangeCodeForSession(legacyToken) ? await client.auth.exchangeCodeForSession(legacyToken)
: await client.auth.verifyOtp({ : await client.auth.verifyOtp({
token_hash: tokenHash!, token_hash: tokenHash!,
type: type as "email" | "recovery" | "magiclink", type: type as "email" | "signup" | "recovery",
}); });
// Older email templates could place a recovery token hash in /code=... // Older email templates could place a recovery token hash in /code=...
// instead of a PKCE code. Only Supabase can decide whether it is valid. // instead of a PKCE code. Only Supabase can decide whether it is valid.

View File

@@ -12,8 +12,11 @@ import { SiteCursor } from "@/components/SiteCursor";
import { I18nProvider } from "@/components/I18nProvider"; import { I18nProvider } from "@/components/I18nProvider";
import { getI18n } from "@/lib/i18n"; import { getI18n } from "@/lib/i18n";
import "./polish.css"; import "./polish.css";
import "./mobile-game.css";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings"; import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n(); const { t } = await getI18n();
return { return {
@@ -31,10 +34,8 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([ const [{ locale, preference, detectedLocale }, settings, account] =
getI18n(), await Promise.all([getI18n(), initialSiteSettings(), user()]);
initialSiteSettings(),
]);
return ( return (
<html lang={locale}> <html lang={locale}>
<body> <body>
@@ -46,6 +47,7 @@ export default async function RootLayout({
<SiteSettingsProvider initialSettings={settings}> <SiteSettingsProvider initialSettings={settings}>
{children} {children}
<SiteCursor /> <SiteCursor />
<LegacyPasswordNotice enabled={!!account?.email} />
</SiteSettingsProvider> </SiteSettingsProvider>
</I18nProvider> </I18nProvider>
</body> </body>

192
app/mobile-game.css Normal file
View File

@@ -0,0 +1,192 @@
.mobile-map-toggle,
.report-icon {
display: none;
}
@media (max-width: 700px) {
.game-page {
min-height: 0;
}
.game-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
gap: 8px;
padding: max(10px, env(safe-area-inset-top))
max(10px, env(safe-area-inset-right)) 12px
max(10px, env(safe-area-inset-left));
}
.game-header .brand {
font-size: 17px;
white-space: nowrap;
gap: 6px;
}
.game-header .brand-cube {
flex-shrink: 0;
}
.game-header .report-trigger {
width: 44px;
height: 44px;
padding: 0;
}
.game-header .report-trigger > span {
display: none;
}
.game-header .report-icon {
display: block;
}
.game-info {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
gap: 8px;
padding: 8px;
border-radius: 10px;
}
.game-info > div {
min-width: 0;
}
.game-info small {
white-space: nowrap;
font-size: 8px;
letter-spacing: 0;
}
.game-info strong {
white-space: nowrap;
font-size: 17px;
line-height: 1.4;
}
.panorama-controls {
top: calc(max(10px, env(safe-area-inset-top)) + 112px);
left: max(10px, env(safe-area-inset-left));
gap: 6px;
}
.panorama-controls button {
width: 44px;
height: 44px;
padding: 0;
}
.game-page .game-map,
.game-page .game-map.expanded {
right: max(8px, env(safe-area-inset-right));
left: max(8px, env(safe-area-inset-left));
bottom: max(10px, env(safe-area-inset-bottom));
width: auto;
max-width: none;
box-shadow: 0 8px 24px #0006;
}
.game-map:not(.expanded) .game-map-content {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-map-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 56px;
padding: 12px;
border: 0;
color: #fff1d0;
background: #241e18;
font: 14px var(--font-pixel, monospace);
}
.mobile-map-toggle small {
font: 11px monospace;
}
.game-map.expanded .mobile-map-toggle {
display: none;
}
.game-map .map-topline {
min-height: 44px;
padding: 0 8px 0 12px;
}
.game-map .map-topline button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
}
.game-page .game-map .map-surface {
height: min(46dvh, 360px);
border-width: 12px;
border-image-width: 12px;
}
.game-map .map-actions {
padding: 8px 10px 10px;
}
.game-map .map-actions .button {
min-height: 48px;
padding: 10px 12px;
font-size: 13px;
white-space: nowrap;
}
.game-map .map-actions small {
margin-bottom: 8px;
}
.game-map .leaflet-bar a {
width: 40px;
height: 40px;
line-height: 40px;
}
.look-help {
display: none;
}
.round-result {
max-width: calc(100% - 24px);
max-height: calc(100dvh - 140px);
overflow-y: auto;
padding: 20px;
}
html[lang] .round-result h2 {
font-size: 21px;
}
.round-result .score {
font-size: 42px;
}
}
@media (max-height: 500px) and (max-width: 950px) {
.game-page {
min-height: 0;
}
.game-header {
display: flex;
padding: 8px 12px;
gap: 10px;
}
.game-header .brand {
font-size: 16px;
}
.game-header .report-trigger {
order: 3;
}
.game-info {
width: auto;
flex: 1;
max-width: 300px;
margin-left: auto;
gap: 16px;
padding: 6px 10px;
}
.panorama-controls {
top: 72px;
left: 12px;
}
.game-page .game-map,
.game-page .game-map.expanded {
left: auto;
width: min(440px, calc(100vw - 80px));
right: 8px;
bottom: max(8px, env(safe-area-inset-bottom));
}
.game-page .game-map .map-surface {
height: max(100px, calc(100dvh - 190px));
}
.round-result {
max-height: calc(100dvh - 80px);
}
}

View File

@@ -134,7 +134,7 @@ export function AccountSettings({
)} )}
<PasswordForm /> <PasswordForm />
<div className="settings-recovery"> <div className="settings-recovery">
<h3>{t("Signed up with an email link?")}</h3> <h3>{t("Forgot your password?")}</h3>
<p className="muted"> <p className="muted">
{t( {t(
"Set your first password, or recover a forgotten one, using a link sent to your account email.", "Set your first password, or recover a forgotten one, using a link sent to your account email.",

View File

@@ -9,6 +9,7 @@ import {
MousePointer2, MousePointer2,
Move, Move,
MapPin, MapPin,
Map as MapIcon,
} from "lucide-react"; } from "lucide-react";
import { Brand } from "./Brand"; import { Brand } from "./Brand";
import { Panorama } from "./Panorama"; import { Panorama } from "./Panorama";
@@ -174,6 +175,7 @@ export function Game({ id }: { id: string }) {
setSession(null); setSession(null);
setResult(null); setResult(null);
setPin(null); setPin(null);
setExpanded(false);
setLoaded({ panorama: false, map: false }); setLoaded({ panorama: false, map: false });
timingOut.current = false; timingOut.current = false;
setError(""); setError("");
@@ -228,6 +230,21 @@ export function Game({ id }: { id: string }) {
</div> </div>
{session && ( {session && (
<div className={"game-map " + (expanded ? "expanded" : "")}> <div className={"game-map " + (expanded ? "expanded" : "")}>
<button
className="mobile-map-toggle"
aria-expanded={expanded}
aria-controls="guess-map-panel"
onClick={() => setExpanded(true)}
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
{pin && (
<small>
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
</small>
)}
</button>
<div className="game-map-content" id="guess-map-panel">
<div className="map-topline"> <div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span> <span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button <button
@@ -264,6 +281,7 @@ export function Game({ id }: { id: string }) {
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
<div className="look-help"> <div className="look-help">
<span> <span>

View File

@@ -59,7 +59,11 @@ export function GuessMap({
zoomSnap: 0, zoomSnap: 0,
maxBounds: b.pad(0.15), maxBounds: b.pad(0.15),
attributionControl: false, attributionControl: false,
zoomControl: false,
}); });
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
// Install cleanup immediately, including when later initialization fails. // Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined; let observer: ResizeObserver | undefined;
cleanup = () => { cleanup = () => {

View File

@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { Copy, KeyRound } from "lucide-react";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { generateInitialPassword } from "@/shared/initial-password";
export function LegacyPasswordNotice({ enabled }: { enabled: boolean }) {
const { t } = useI18n();
const pathname = usePathname();
const dialog = useRef<HTMLDialogElement>(null);
const [needed, setNeeded] = useState(false);
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled || pathname?.startsWith("/auth/") || password) return;
let active = true;
api<{ needed: boolean }>("auth/initial-password")
.then((data) => {
if (active) setNeeded(data.needed);
})
.catch(() => {});
return () => {
active = false;
};
}, [enabled, pathname, password]);
useEffect(() => {
if (!needed) return;
const element = dialog.current;
element?.showModal();
return () => {
element?.close();
};
}, [needed]);
async function generate() {
if (busy) return;
setBusy(true);
setError("");
const generated = generateInitialPassword();
try {
await api("auth/initial-password", { password: generated });
// Only keep the confirmed password in this mounted dialog's memory.
setPassword(generated);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function dismiss() {
setNeeded(false);
setPassword("");
setSaved(false);
setCopied(false);
setError("");
}
async function copy() {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
setError("Select the password and copy it manually.");
}
}
if (!needed) return null;
return (
<dialog
ref={dialog}
className="report-dialog legacy-password-dialog"
aria-labelledby="legacy-password-title"
onCancel={(event) => {
if (password || busy) event.preventDefault();
else dismiss();
}}
>
<KeyRound size={28} aria-hidden="true" />
<h2 id="legacy-password-title">
{password
? t("Your one-time password screen.")
: t("Your account needs a password.")}
</h2>
<p>
{password
? t(
"Save this password now. After you close this screen, it cannot be shown again. You can change it in account settings.",
)
: t(
"You joined before passwords were required. Create a strong password for email-and-password login.",
)}
</p>
{password ? (
<>
<label className="field">
{t("Your new account password")}
<input
readOnly
value={password}
autoComplete="off"
spellCheck={false}
/>
</label>
<button className="button" onClick={copy}>
<Copy size={16} aria-hidden="true" />
{copied ? t("Copied") : t("Copy password")}
</button>
<label className="password-saved">
<input
type="checkbox"
checked={saved}
onChange={(e) => setSaved(e.target.checked)}
/>
{t("I saved this password somewhere safe.")}
</label>
<button
className="button primary full"
disabled={!saved}
onClick={dismiss}
>
{t("Done — I saved it")}
</button>
</>
) : (
<>
<button
className="button primary full"
disabled={busy}
onClick={generate}
>
{busy ? t("Creating password…") : t("Create and show my password")}
</button>
<button className="text-link" disabled={busy} onClick={dismiss}>
{t("Not now")}
</button>
</>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</dialog>
);
}

View File

@@ -39,21 +39,21 @@ export function Panorama({
const element = mount.current!; const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 }; view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => { const down = (e: PointerEvent) => {
if (e.button !== 0) return; if (e.button !== 0 || drag) return;
element.setPointerCapture(e.pointerId); element.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY }; drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
}, },
move = (e: PointerEvent) => { move = (e: PointerEvent) => {
if (!drag) return; if (!drag || drag.pointerId !== e.pointerId) return;
view.current.yaw -= (e.clientX - drag.x) * 0.003; view.current.yaw -= (e.clientX - drag.x) * 0.003;
view.current.pitch = Math.max( view.current.pitch = Math.max(
-1.5, -1.5,
Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003), Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003),
); );
drag = { x: e.clientX, y: e.clientY }; drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
}, },
up = () => { up = (e: PointerEvent) => {
drag = null; if (drag?.pointerId === e.pointerId) drag = null;
}, },
wheel = (e: WheelEvent) => { wheel = (e: WheelEvent) => {
if (!zoom) return; if (!zoom) return;
@@ -70,7 +70,7 @@ export function Panorama({
"The panorama graphics context was lost. Reconnect to restore the view.", "The panorama graphics context was lost. Reconnect to restore the view.",
); );
}; };
let drag: { x: number; y: number } | null = null; let drag: { x: number; y: number; pointerId: number } | null = null;
if (urls.length !== 6) { if (urls.length !== 6) {
callbacks.current.onError( callbacks.current.onError(
"This panorama is missing one or more of its six faces.", "This panorama is missing one or more of its six faces.",
@@ -133,6 +133,7 @@ export function Panorama({
element.addEventListener("pointermove", move); element.addEventListener("pointermove", move);
element.addEventListener("pointerup", up); element.addEventListener("pointerup", up);
element.addEventListener("pointercancel", up); element.addEventListener("pointercancel", up);
element.addEventListener("lostpointercapture", up);
element.addEventListener("wheel", wheel, { passive: false }); element.addEventListener("wheel", wheel, { passive: false });
callbacks.current.onReady(); callbacks.current.onReady();
}) })
@@ -150,6 +151,7 @@ export function Panorama({
element.removeEventListener("pointermove", move); element.removeEventListener("pointermove", move);
element.removeEventListener("pointerup", up); element.removeEventListener("pointerup", up);
element.removeEventListener("pointercancel", up); element.removeEventListener("pointercancel", up);
element.removeEventListener("lostpointercapture", up);
element.removeEventListener("wheel", wheel); element.removeEventListener("wheel", wheel);
geometry?.dispose(); geometry?.dispose();
material?.dispose(); material?.dispose();

View File

@@ -4,6 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { reportCategories } from "@/shared/moderation"; import { reportCategories } from "@/shared/moderation";
import { Flag } from "lucide-react";
export function ReportContent({ export function ReportContent({
datasetId, datasetId,
@@ -66,14 +67,16 @@ export function ReportContent({
return ( return (
<> <>
<button <button
className="button" className="button report-trigger"
aria-label={t("Report content")}
onClick={() => { onClick={() => {
setOpen(true); setOpen(true);
setError(""); setError("");
setMessage(""); setMessage("");
}} }}
> >
{t("Report content")} <Flag className="report-icon" size={18} aria-hidden="true" />
<span>{t("Report content")}</span>
</button> </button>
{message && ( {message && (
<p role="status" className="success-message"> <p role="status" className="success-message">

View File

@@ -7,7 +7,7 @@ import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { authActionSchema, authDestination } from "@/shared/account"; import { authActionSchema, authDestination } from "@/shared/account";
type Mode = "login" | "signup" | "recover" | "magic"; type Mode = "login" | "signup" | "recover";
export function SignIn({ export function SignIn({
next = "/dashboard", next = "/dashboard",
initialError = "", initialError = "",
@@ -41,8 +41,6 @@ export function SignIn({
? { action: mode, email, password, displayName: name, next } ? { action: mode, email, password, displayName: name, next }
: mode === "login" : mode === "login"
? { action: mode, email, password, next } ? { action: mode, email, password, next }
: mode === "magic"
? { action: mode, email, next }
: { action: mode, email }; : { action: mode, email };
const valid = authActionSchema.safeParse(body); const valid = authActionSchema.safeParse(body);
if (!valid.success) { if (!valid.success) {
@@ -75,7 +73,6 @@ export function SignIn({
login: "Welcome back.", login: "Welcome back.",
signup: "Make yourself at home.", signup: "Make yourself at home.",
recover: "Find your way back.", recover: "Find your way back.",
magic: "An email, a way in.",
}[mode]; }[mode];
return ( return (
<div className="auth-layout"> <div className="auth-layout">
@@ -132,10 +129,6 @@ export function SignIn({
? t("Choose the name people will see on your maps.") ? t("Choose the name people will see on your maps.")
: mode === "recover" : mode === "recover"
? t("We’ll email you a link to set a new password.") ? t("We’ll email you a link to set a new password.")
: mode === "magic"
? t(
"For existing accounts. Open the link on this device to continue.",
)
: t("Log in with your email and password.")} : t("Log in with your email and password.")}
</p> </p>
<form onSubmit={submit} aria-busy={busy}> <form onSubmit={submit} aria-busy={busy}>
@@ -230,7 +223,7 @@ export function SignIn({
</p> </p>
)} )}
<button className="button primary full" disabled={busy}> <button className="button primary full" disabled={busy}>
{mode === "recover" || mode === "magic" ? ( {mode === "recover" ? (
<Mail size={17} /> <Mail size={17} />
) : ( ) : (
<ArrowRight size={17} /> <ArrowRight size={17} />
@@ -241,24 +234,10 @@ export function SignIn({
login: t("Log in"), login: t("Log in"),
signup: t("Create account"), signup: t("Create account"),
recover: t("Send reset link"), recover: t("Send reset link"),
magic: t("Send sign-in link"),
}[mode]} }[mode]}
</button> </button>
</form> </form>
{mode === "login" && ( {mode === "recover" && (
<div className="auth-alternative">
<span>{t("Already use email links?")}</span>
<button
type="button"
className="text-link"
disabled={busy}
onClick={() => changeMode("magic")}
>
{t("Send me a sign-in link")}
</button>
</div>
)}
{(mode === "recover" || mode === "magic") && (
<button <button
type="button" type="button"
className="text-link auth-back" className="text-link auth-back"

View File

@@ -8,10 +8,12 @@ import {
authDestination, authDestination,
passwordUpdateSchema, passwordUpdateSchema,
profileUpdateSchema, profileUpdateSchema,
initialPasswordSchema,
} from "@/shared/account"; } from "@/shared/account";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery"; import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
import { requestOrigin } from "./request-origin"; import { requestOrigin } from "./request-origin";
import { issueInitialPassword } from "@/shared/initial-password";
type Context = { type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>; jsonBody: (r: NextRequest) => Promise<unknown>;
@@ -24,6 +26,40 @@ export async function accountApi(
path: string[], path: string[],
context: Context, context: Context,
) { ) {
if (
path[0] === "auth" &&
path[1] === "initial-password" &&
path.length === 2
) {
const account = await user();
if (!account?.email || !account.email_confirmed_at)
return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") {
return json({
needed: required(
await admin().rpc("legacy_password_needed", { p_user: account.id }),
),
});
}
if (request.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const body = initialPasswordSchema.parse(await context.jsonBody(request));
await context.limit(`initial-password:${account.id}`, 3, 600);
await issueInitialPassword(account.id, body.password, {
claim: async (id) =>
required(await admin().rpc("claim_legacy_password", { p_user: id })),
update: async (id, password) => {
const { error } = await admin().auth.admin.updateUserById(id, {
password,
});
if (error)
throw new Error(
"Password setup could not be confirmed. Use password recovery before signing out.",
);
},
});
return json({ ok: true });
}
if (path[0] === "profile" && path.length === 1) { if (path[0] === "profile" && path.length === 1) {
const account = await user(); const account = await user();
if (!account) return json({ error: "Log in to manage your account" }, 401); if (!account) return json({ error: "Log in to manage your account" }, 401);
@@ -63,6 +99,10 @@ export async function accountApi(
const origin = requestOrigin(request, process.env.MINEGEO_URL); const origin = requestOrigin(request, process.env.MINEGEO_URL);
const next = authDestination("next" in body ? body.next : null); const next = authDestination("next" in body ? body.next : null);
const callback = new URL("/auth/callback", origin); const callback = new URL("/auth/callback", origin);
callback.searchParams.set(
"type",
body.action === "recover" ? "recovery" : "signup",
);
callback.searchParams.set( callback.searchParams.set(
"next", "next",
body.action === "recover" body.action === "recover"
@@ -107,17 +147,8 @@ export async function accountApi(
"Check your email to confirm your account. If you already have an account, log in or reset your password.", "Check your email to confirm your account. If you already have an account, log in or reset your password.",
}); });
} }
const { error } = const { error } = await client.auth.resetPasswordForEmail(body.email, {
body.action === "recover"
? await client.auth.resetPasswordForEmail(body.email, {
redirectTo: callback.toString(), redirectTo: callback.toString(),
})
: await client.auth.signInWithOtp({
email: body.email,
options: {
shouldCreateUser: false,
emailRedirectTo: callback.toString(),
},
}); });
if ( if (
error && error &&
@@ -126,9 +157,7 @@ export async function accountApi(
throw new Error(error.message); throw new Error(error.message);
return json({ return json({
message: message:
body.action === "recover" "If an account exists for this email, a password reset link is on its way. Check your inbox.",
? "If an account exists for this email, a password reset link is on its way. Check your inbox."
: "If an account exists for this email, a sign-in link is on its way. Check your inbox.",
}); });
} }
if (path[1] !== "password") return json({ error: "Not found" }, 404); if (path[1] !== "password") return json({ error: "Not found" }, 404);

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
create schema auth;
create function auth.uid() returns uuid language sql as $$ select nullif(current_setting('test.uid',true),'')::uuid $$;
create table auth.users(id uuid primary key,encrypted_password text,email text,email_confirmed_at timestamptz,is_anonymous boolean);
insert into auth.users values
('00000000-0000-4000-8000-000000000001','', 'legacy@example.com',now(),false),
('00000000-0000-4000-8000-000000000002','existing-hash','password@example.com',now(),false),
('00000000-0000-4000-8000-000000000003',null,'unverified@example.com',null,false),
('00000000-0000-4000-8000-000000000004',null,null,null,true);`);
await db.exec(
await readFile(
"supabase/migrations/20261007055439_legacy_password_claims.sql",
"utf8",
),
);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
await assert.rejects(
db.query(
"select public.claim_legacy_password('00000000-0000-4000-8000-000000000001')",
),
/permission denied/,
);
await assert.rejects(
db.query(
"select public.legacy_password_needed('00000000-0000-4000-8000-000000000001')",
),
/permission denied/,
);
await db.exec("reset role");
}
await db.exec("set role service_role");
const call = async (fn, ending) =>
(
await db.query(
`select public.${fn}('00000000-0000-4000-8000-00000000000${ending}') as result`,
)
).rows[0].result;
assert.equal(await call("legacy_password_needed", 1), true);
for (const ending of [2, 3, 4, 5]) {
assert.equal(await call("legacy_password_needed", ending), false);
assert.equal(await call("claim_legacy_password", ending), false);
}
await db.exec(
"select set_config('test.uid','00000000-0000-4000-8000-000000000002',false)",
);
assert.equal(await call("claim_legacy_password", 1), false);
await db.exec("select set_config('test.uid','',false)");
assert.equal(await call("claim_legacy_password", 1), true);
assert.equal(await call("claim_legacy_password", 1), false);
assert.equal(await call("legacy_password_needed", 1), false);
const columns = (
await db.query(
"select column_name from information_schema.columns where table_schema='minegeo_private' and table_name='legacy_password_claims'",
)
).rows.map((r) => r.column_name);
assert.deepEqual(columns.sort(), ["claimed_at", "user_id"]);
console.log(
"Initial password SQL passed: verified legacy owners eligible once; existing passwords protected; anonymous, unverified and cross-account attempts denied; no password stored.",
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
await db.close();
}

View File

@@ -36,13 +36,13 @@ export const authActionSchema = z.discriminatedUnion("action", [
}) })
.strict(), .strict(),
z.object({ action: z.literal("recover"), email }).strict(), z.object({ action: z.literal("recover"), email }).strict(),
z
.object({ action: z.literal("magic"), email, next: z.string().optional() })
.strict(),
]); ]);
export const profileUpdateSchema = z export const profileUpdateSchema = z
.object({ displayName: displayNameSchema }) .object({ displayName: displayNameSchema })
.strict(); .strict();
export const initialPasswordSchema = z
.object({ password: newPasswordSchema })
.strict();
export const passwordUpdateSchema = z export const passwordUpdateSchema = z
.object({ .object({
password: newPasswordSchema, password: newPasswordSchema,

View File

@@ -540,6 +540,8 @@ export type Database = {
[_ in never]: never; [_ in never]: never;
}; };
Functions: { Functions: {
legacy_password_needed: { Args: { p_user: string }; Returns: boolean };
claim_legacy_password: { Args: { p_user: string }; Returns: boolean };
set_beta_badge: { set_beta_badge: {
Args: { p_reviewer: string; p_enabled: boolean }; Args: { p_reviewer: string; p_enabled: boolean };
Returns: boolean; Returns: boolean;

View File

@@ -0,0 +1,28 @@
import { initialPasswordSchema } from "./account";
export function generateInitialPassword() {
const bytes = crypto.getRandomValues(new Uint8Array(24));
// Every byte contributes eight random bits, encoded as two hex characters.
return (
"Aa7!" +
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
);
}
export async function issueInitialPassword(
userId: string,
password: string,
operations: {
claim: (id: string) => Promise<boolean>;
update: (id: string, password: string) => Promise<void>;
},
) {
initialPasswordSchema.parse({ password });
if (!(await operations.claim(userId)))
throw new Error(
"A password is already set or was already issued. Use password recovery if needed.",
);
// A claim is never released after an uncertain Auth response: the password
// may already have changed, so a retry must not overwrite it with a new one.
await operations.update(userId, password);
}

View File

@@ -587,5 +587,23 @@
"Hidden for all players": "Скрыт для всех игроков", "Hidden for all players": "Скрыт для всех игроков",
"Hide beta badge": "Скрыть значок BETA", "Hide beta badge": "Скрыть значок BETA",
"Show beta badge": "Показать значок BETA", "Show beta badge": "Показать значок BETA",
"Setting saved. Open pages update within 30 seconds.": "Настройка сохранена. Открытые страницы обновятся в течение 30 секунд." "Setting saved. Open pages update within 30 seconds.": "Настройка сохранена. Открытые страницы обновятся в течение 30 секунд.",
"Forgot your password?": "Забыл пароль?",
"Open guess map": "Открыть карту",
"Edit your guess": "Изменить метку",
"Your one-time password screen.": "Сохрани свой пароль.",
"Your account needs a password.": "Для аккаунта нужен пароль.",
"Save this password now. After you close this screen, it cannot be shown again. You can change it in account settings.": "Сохрани пароль сейчас. После закрытия этого окна его нельзя будет показать снова. Сменить пароль можно в настройках аккаунта.",
"You joined before passwords were required. Create a strong password for email-and-password login.": "Ты зарегистрировался, когда пароль ещё не требовался. Создай надёжный пароль для входа по почте и паролю.",
"Your new account password": "Твой новый пароль",
"Copy password": "Скопировать пароль",
"Copied": "Скопировано",
"I saved this password somewhere safe.": "Я сохранил пароль в надёжном месте.",
"Done — I saved it": "Готово — пароль сохранён",
"Creating password…": "Создаём пароль…",
"Create and show my password": "Создать и показать пароль",
"Not now": "Не сейчас",
"Select the password and copy it manually.": "Выдели пароль и скопируй его вручную.",
"A password is already set or was already issued. Use password recovery if needed.": "Пароль уже установлен или был выдан ранее. Если нужно, воспользуйся восстановлением пароля.",
"Password setup could not be confirmed. Use password recovery before signing out.": "Не удалось подтвердить установку пароля. Восстанови пароль перед выходом из аккаунта."
} }

View File

@@ -1 +1 @@
v2.119.0 v2.120.0

View File

@@ -0,0 +1,50 @@
create schema if not exists minegeo_private;
revoke all on schema minegeo_private from public, anon, authenticated;
grant usage on schema minegeo_private to service_role;
create table minegeo_private.legacy_password_claims (
user_id uuid primary key references auth.users(id) on delete cascade,
claimed_at timestamptz not null default now()
);
alter table minegeo_private.legacy_password_claims enable row level security;
revoke all on minegeo_private.legacy_password_claims from public, anon, authenticated;
grant select, insert on minegeo_private.legacy_password_claims to service_role;
create policy "Server reads password claims" on minegeo_private.legacy_password_claims
for select to service_role using (true);
create policy "Server creates password claims" on minegeo_private.legacy_password_claims
for insert to service_role with check (true);
-- This private helper reads only password eligibility, never returns a hash,
-- and never stores a plaintext password. Only the trusted server can call it.
create function minegeo_private.legacy_password(p_user uuid, p_claim boolean)
returns boolean language plpgsql security definer set search_path = '' as $$
declare eligible boolean; claimed uuid;
begin
if auth.uid() is not null and auth.uid() <> p_user then return false; end if;
select coalesce(encrypted_password, '') = '' and email is not null
and email_confirmed_at is not null and not coalesce(is_anonymous, false)
into eligible from auth.users where id = p_user for update;
if not coalesce(eligible, false) then return false; end if;
if not p_claim then
return not exists(select 1 from minegeo_private.legacy_password_claims where user_id = p_user);
end if;
insert into minegeo_private.legacy_password_claims(user_id) values(p_user)
on conflict(user_id) do nothing returning user_id into claimed;
return claimed is not null;
end;
$$;
revoke all on function minegeo_private.legacy_password(uuid,boolean) from public, anon, authenticated;
grant execute on function minegeo_private.legacy_password(uuid,boolean) to service_role;
create function public.legacy_password_needed(p_user uuid)
returns boolean language sql security invoker set search_path = '' as $$
select minegeo_private.legacy_password(p_user, false);
$$;
create function public.claim_legacy_password(p_user uuid)
returns boolean language sql security invoker set search_path = '' as $$
select minegeo_private.legacy_password(p_user, true);
$$;
revoke all on function public.legacy_password_needed(uuid) from public, anon, authenticated;
revoke all on function public.claim_legacy_password(uuid) from public, anon, authenticated;
grant execute on function public.legacy_password_needed(uuid) to service_role;
grant execute on function public.claim_legacy_password(uuid) to service_role;

View File

@@ -0,0 +1,112 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
authActionSchema,
initialPasswordSchema,
newPasswordSchema,
} from "../shared/account";
import {
generateInitialPassword,
issueInitialPassword,
} from "../shared/initial-password";
test("email-link sign-in is rejected, password login and recovery remain supported", () => {
assert.equal(
authActionSchema.safeParse({ action: "magic", email: "owner@example.com" })
.success,
false,
);
assert.equal(
authActionSchema.safeParse({
action: "recover",
email: "owner@example.com",
}).success,
true,
);
assert.equal(
authActionSchema.safeParse({
action: "login",
email: "owner@example.com",
password: "existing-password",
}).success,
true,
);
});
test("generated first passwords are strong, unique and cannot target another account", () => {
const passwords = new Set(
Array.from({ length: 100 }, generateInitialPassword),
);
assert.equal(passwords.size, 100);
for (const password of passwords) {
assert.equal(newPasswordSchema.safeParse(password).success, true);
assert.ok(
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/\d/.test(password) &&
/[!]/.test(password),
);
}
assert.equal(
initialPasswordSchema.safeParse({
password: generateInitialPassword(),
userId: "someone-else",
}).success,
false,
);
});
test("first password issuance cannot overwrite a password or replay an uncertain Auth update", async () => {
let claimed = false,
updates = 0;
const operations = {
claim: async () => {
if (claimed) return false;
claimed = true;
return true;
},
update: async (id: string) => {
assert.equal(id, "verified-owner");
updates++;
},
};
await issueInitialPassword(
"verified-owner",
generateInitialPassword(),
operations,
);
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
operations,
),
/already/,
);
assert.equal(updates, 1);
let uncertainClaimed = false;
const uncertain = {
claim: async () => {
if (uncertainClaimed) return false;
uncertainClaimed = true;
return true;
},
update: async () => {
throw new Error("network interrupted");
},
};
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
uncertain,
),
/network interrupted/,
);
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
uncertain,
),
/already/,
);
});

File diff suppressed because one or more lines are too long