diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index c98d434d..a78dfb15 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -50,6 +50,8 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) +# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one. +_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300) _verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300) _request_access_limiter = RateLimiter(max_events=5, window_seconds=300) _suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) @@ -638,6 +640,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: return raw +def _peek_token(db: Session, raw: str | None, kind: str) -> User | None: + """Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and + the same "does its user still exist" answer, so the two can never disagree about a given token. + + Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and + this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the + authority, because a token can expire, be spent, or lose its account between the two calls. + """ + if not raw: + return None + row = db.execute( + select(AuthToken).where( + AuthToken.token_hash == hash_token(raw), + AuthToken.kind == kind, + AuthToken.used_at.is_(None), + AuthToken.expires_at >= datetime.now(timezone.utc), + ) + ).scalar_one_or_none() + # db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is + # belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck. + return db.get(User, row.user_id) if row is not None else None + + def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: """Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a @@ -766,9 +791,7 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict: flag is rare now — but it must stay, because those users have no other way back in.""" user = _consume_token(db, body.token, "verify") if user is None: - raise HTTPException( - status_code=400, detail="This verification link is invalid or has expired." - ) + raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"}) user.email_verified = True needs_password = user.password_hash is None and user.password_revoked_at is not None email = user.email @@ -916,6 +939,26 @@ def _send_reset_if_eligible(email: str) -> None: email_mod.send_password_reset(user.email, f"{_app_base()}/#reset={raw}") +@router.post("/password-reset/check") +def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict: + """Is the reset link the SPA just opened still good? Answers without spending the token. + + Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment + was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an + account and then opening its reset link — the user types a new password into a form that answers + 400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands + in spam, and whoever got two mails usually clicks the older one. + + Not an enumeration oracle: probing requires already holding a 43-character random, and the answer + is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is + told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot + answer it must degrade to the old behaviour instead of declaring a live link dead. + """ + if not _reset_check_limiter.allow(_client_ip(request)): + return {"ok": True} + return {"ok": _peek_token(db, body.token, "reset") is not None} + + @router.post("/password-reset/confirm") def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict: """Set a new password from a valid reset token, then invalidate any other reset tokens.""" @@ -924,11 +967,11 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) if len(new_password) < PASSWORD_MIN_LEN: raise HTTPException( status_code=400, - detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", + detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN}, ) user = _consume_token(db, token, "reset") if user is None: - raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") + raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"}) user.password_hash = hash_password(new_password) user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent # A valid reset link proves control of the mailbox, so completing a reset also verifies the diff --git a/backend/tests/test_reset_link_states.py b/backend/tests/test_reset_link_states.py new file mode 100644 index 00000000..a11c03d4 --- /dev/null +++ b/backend/tests/test_reset_link_states.py @@ -0,0 +1,90 @@ +"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane. + +Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full +"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after +the user typed a password did the server answer 400. No security hole (the account's tokens cascade +away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly +the kind this epic exists to remove. + +The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their +one real attempt. That is the property most worth pinning here. +""" + +from app.routes.auth import RESET_TTL, _issue_token + +_PW = "correct horse battery" +_NEW = "brand new password" + + +def _issue(db, user): + raw = _issue_token(db, user, "reset", RESET_TTL) + db.commit() + return raw + + +def test_checking_a_live_link_does_not_spend_it(client, db, make_user): + raw = _issue(db, make_user(email="live@example.com", password=_PW)) + + assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True} + assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True} + + # The whole point: after any number of checks the one real attempt is still there. + assert client.post( + "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} + ).json() == {"ok": True} + + +def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user): + user = make_user(email="gone@example.com", password=_PW) + raw = _issue(db, user) + db.delete(user) + db.commit() + + assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False} + + # And the confirm agrees, in the structured shape the card renders as a named screen — the two + # must never disagree about one token. + r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW}) + assert r.status_code == 400 + assert r.json()["detail"] == {"code": "reset", "reason": "invalid"} + + +def test_a_spent_link_is_reported_dead(client, db, make_user): + raw = _issue(db, make_user(email="spent@example.com", password=_PW)) + client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW}) + + assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False} + + +def test_a_too_short_password_does_not_burn_the_link(client, db, make_user): + raw = _issue(db, make_user(email="short@example.com", password=_PW)) + + r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"}) + + assert r.status_code == 400 + assert r.json()["detail"]["reason"] == "weak_password" + # A mistyped password must not cost the link — the length check runs BEFORE the token is spent, + # and the card tells the two failures apart by `reason` so only one of them takes the form down. + assert client.post( + "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} + ).json() == {"ok": True} + + +def test_a_dead_verification_link_answers_in_the_same_structured_shape(client): + r = client.post("/auth/verify", json={"token": "no-such-token"}) + + assert r.status_code == 400 + assert r.json()["detail"] == {"code": "verify", "reason": "invalid"} + + +def test_the_check_never_reports_a_live_link_dead_when_throttled( + client, db, make_user, monkeypatch +): + from app.routes import auth as auth_routes + + raw = _issue(db, make_user(email="throttled@example.com", password=_PW)) + monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False) + + # Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead" + # would lock someone out of a link that works. + assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True} diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index a37aecb9..203ab53c 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -11,6 +11,7 @@ import { Expand, Filter, Inbox, + Link2Off, ListVideo, Lock, MailWarning, @@ -23,7 +24,7 @@ import { // Siftlode is open source; the landing footer links to the public repository. const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode"; -import { api, loginBlocked, type LoginBlocked } from "../lib/api"; +import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api"; import { HttpError } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; @@ -307,6 +308,30 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose ); } +// One answer per reset token, however many times React mounts this card. StrictMode double-invokes +// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on +// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget +// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the +// in-flight promise means a remount joins the request already running instead of starting another. +const resetTokenChecks = new Map>(); + +function checkResetToken(token: string): Promise { + let pending = resetTokenChecks.get(token); + if (!pending) { + pending = api + .checkPasswordReset(token) + .then((r) => r.ok) + // A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets + // itself: a network blip must never be cached as "this link is dead". + .catch(() => { + resetTokenChecks.delete(token); + return true; + }); + resetTokenChecks.set(token, pending); + } + return pending; +} + function AuthCard({ verify }: { verify: VerifyEntry }) { const { t } = useTranslation(); // The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can @@ -355,6 +380,31 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { // (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven // correct, so there is nothing to retype — leaving the form up would only invite a pointless retry. const [blocked, setBlocked] = useState(null); + // The emailed reset link turned out to be spent, expired, or to belong to an account that no longer + // exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen. + const [deadReset, setDeadReset] = useState(false); + // Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank + // until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link + // rendered a full "set a new password" form that could only ever answer 400 — the user finds out + // after typing. This is advisory (the confirm below stays the authority); a check that errors is + // treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link. + const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null); + useEffect(() => { + const token = entryHash.get("reset"); + if (!token) return; + let live = true; + void checkResetToken(token).then((ok) => { + if (!live) return; + if (!ok) { + setDeadReset(true); + setResetToken(null); // and stop the reset view being the card's root, so Back can't return + } + setCheckingReset(false); + }); + return () => { + live = false; + }; + }, []); // `done` and `error` belong to the view that produced them, and the render is `done ? msg : form` // — without this, Back out of a submitted form would show the old success message under the // previous view's title. Clearing on view change covers both Back/Forward and the mode links. @@ -362,6 +412,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { setDone(""); setError(""); setBlocked(null); + setDeadReset(false); }, [mode]); // Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated @@ -375,6 +426,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { setError(""); setDone(""); setBlocked(null); + setDeadReset(false); // The verify/login banners are one-time entry notices — dismiss once the user takes any // deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens. verify.dismiss(); @@ -438,6 +490,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { setBlocked(refusal); return; } + // The token died between the pre-flight check and this submit — or was never checked, because + // the probe itself failed. Either way the answer is the same screen, not a red line. + if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) { + setDeadReset(true); + setResetToken(null); + return; + } setError( err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"), ); @@ -466,15 +525,21 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { } } - const titleKey = blocked ? `welcome.auth.blocked.${blocked.reason}.title` : TITLE_KEY[mode]; - const BlockedIcon = blocked ? BLOCKED_ICON[blocked.reason] : null; + // A named state owns the card's heading while it is up, so it reads as the card's subject rather + // than a box sitting inside a heading that still says "Set a new password". + const titleKey = blocked + ? `welcome.auth.blocked.${blocked.reason}.title` + : deadReset + ? "welcome.auth.deadLink.title" + : TITLE_KEY[mode]; + const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null; return (

- {BlockedIcon && ( + {StateIcon && ( - + )} {t(titleKey)} @@ -527,6 +592,19 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { {blocked ? ( setBlocked(null)} /> + ) : deadReset ? ( + { + reset(); + setMode("signin"); + }} + /> + ) : checkingReset ? ( + // Blank rather than the form: showing a form we may be about to withdraw is the flicker this + // whole check exists to remove. +

{t("welcome.auth.working")}

) : done ? (

{done}

@@ -698,6 +776,43 @@ const BLOCKED_ICON: Partial> = { suspended: ShieldAlert, }; +// The frame every named state in this card shares: a sentence, whatever detail that state can give, +// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state +// reads as the card's subject. One frame, because two states that mean "this form cannot succeed" +// should not drift apart in shape. +function StatePanel({ + body, + cta, + onBack, + children, +}: { + body: string; + cta?: { label: string; onClick: () => void }; + onBack: () => void; + children?: React.ReactNode; +}) { + const { t } = useTranslation(); + return ( +
+

{body}

+ {children} +
+ + {cta && ( + + )} +
+
+ ); +} + // The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the // account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval" // string it replaces was true and useless: it never revealed that the address the account carried was @@ -716,11 +831,15 @@ function BlockedPanel({ // Suspension is not a gate that will open on its own, so the checklist would be a lie there. const gated = reason !== "suspended"; return ( -
-

- {t(`welcome.auth.blocked.${reason}.body`)} -

- + {gated && (
@@ -744,21 +863,7 @@ function BlockedPanel({ {t("welcome.auth.blocked.operator", { contact: operator })}

)} - -
- - {reason === "email_unverified" && ( - - )} -
-
+
); } diff --git a/frontend/src/components/authCardStates.dom.test.tsx b/frontend/src/components/authCardStates.dom.test.tsx index a713e02e..9c03bdc4 100644 --- a/frontend/src/components/authCardStates.dom.test.tsx +++ b/frontend/src/components/authCardStates.dom.test.tsx @@ -51,7 +51,7 @@ async function mountCard(url: string, apiOverrides: Record = {} await act(async () => { render(); }); - return { HttpError }; + return { HttpError, Welcome }; } /** Fill the sign-in form and submit it. */ @@ -148,6 +148,96 @@ describe("a refused sign-in becomes a named state", () => { }); }); +// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new +// password" form purely because a `#reset=` fragment was present, and only told the user after they +// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and +// whoever got two mails clicks the older one. +describe("an emailed reset link that is already finished", () => { + it("refuses to draw a form that cannot succeed", async () => { + await mountCard("/#reset=dead-token", { + checkPasswordReset: () => Promise.resolve({ ok: false }), + }); + + expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy(); + expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy(); + expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull(); + }); + + it("draws the form when the link is still good", async () => { + await mountCard("/#reset=live-token", { + checkPasswordReset: () => Promise.resolve({ ok: true }), + }); + + expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull(); + }); + + it("takes the form down if the link dies between the check and the submit", async () => { + const confirmPasswordReset = vi.fn(); + const { HttpError } = await mountCard("/#reset=racing-token", { + checkPasswordReset: () => Promise.resolve({ ok: true }), + confirmPasswordReset, + }); + confirmPasswordReset.mockImplementation(() => + Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })), + ); + + fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), { + target: { value: "brand new password" }, + }); + await act(async () => { + fireEvent.click(screen.getByText("welcome.auth.resetButton")); + }); + + expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy(); + }); + + it("keeps the form up for a too-short password, which costs the link nothing", async () => { + const confirmPasswordReset = vi.fn(); + const { HttpError } = await mountCard("/#reset=live-token", { + checkPasswordReset: () => Promise.resolve({ ok: true }), + confirmPasswordReset, + }); + confirmPasswordReset.mockImplementation(() => + Promise.reject( + new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }), + ), + ); + + fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), { + target: { value: "0123456789ab" }, + }); + await act(async () => { + fireEvent.click(screen.getByText("welcome.auth.resetButton")); + }); + + expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); + expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull(); + }); + + it("probes a token once, however many times the card mounts", async () => { + const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true }); + const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset }); + + await act(async () => { + render(); + }); + + // StrictMode double-invokes effects and the shell remounts this card as the session probe + // settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads + // and silently drop back to drawing the form unchecked. + expect(checkPasswordReset).toHaveBeenCalledTimes(1); + }); + + it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => { + await mountCard("/#reset=live-token", { + checkPasswordReset: () => Promise.reject(new Error("network")), + }); + + expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); + }); +}); + describe("the access-request notice from a denied Google sign-in", () => { it("names the address the request was recorded for", async () => { await mountCard("/#access=requested&email=nptemu%40gmail.com"); diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index 7338cb94..15c3686f 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -85,6 +85,12 @@ "registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.", "forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.", "resetDone": "Your password has been set — you can sign in now.", + "deadLink": { + "title": "This link is no longer valid", + "body": "A password reset link lasts one hour and works once. This one has expired, has already been used, or belongs to an account that no longer exists — so there is nothing to set a password on. Ask for a fresh one and we'll email it.", + "short": "This reset link is no longer valid.", + "cta": "Request a new link" + }, "verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.", "verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.", "verifyOkResetCta": "Set a new password", diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index af1b78e5..fd3cd8e1 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -85,6 +85,12 @@ "registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.", "forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.", "resetDone": "A jelszavad be van állítva — most már beléphetsz.", + "deadLink": { + "title": "Ez a link már nem érvényes", + "body": "A jelszó-visszaállító link egy óráig él, és egyszer használható. Ez lejárt, már felhasználták, vagy olyan fiókhoz tartozik, ami már nem létezik — nincs mire jelszót állítani. Kérj újat, és elküldjük.", + "short": "Ez a visszaállító link már nem érvényes.", + "cta": "Új link kérése" + }, "verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.", "verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.", "verifyOkResetCta": "Új jelszó beállítása", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fd9349ff..afcc11e8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -27,7 +27,7 @@ import { accountApi } from "./api/account"; export type * from "./api/account"; import { authApi } from "./api/auth"; export type * from "./api/auth"; -export { loginBlocked } from "./api/auth"; +export { loginBlocked, deadResetLink } from "./api/auth"; import { setupApi } from "./api/setup"; export type * from "./api/setup"; import { feedApi } from "./api/feed"; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index f3df502f..946bb54b 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -19,6 +19,14 @@ export type LoginBlocked = { operator?: string; }; +/** Did a password-reset call fail because the LINK itself is finished — spent, expired, or belonging + * to an account that no longer exists — rather than because the new password was too short? The two + * arrive as the same 400, and only the first one means the form should come down. */ +export function deadResetLink(detail: unknown): boolean { + const d = detail as { code?: string; reason?: string } | null | undefined; + return d?.code === "reset" && d.reason === "invalid"; +} + const LOGIN_REASONS = new Set([ "email_unverified", "pending_approval", @@ -72,6 +80,15 @@ export const authApi = { { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }, ), + // Is a `#reset=` token still usable? Answers WITHOUT spending it, so the card can refuse to draw a + // form that cannot succeed (a deleted account, an expired or already-used link). Advisory only — + // confirmPasswordReset stays the authority, since the token can die between the two calls. + checkPasswordReset: (token: string): Promise<{ ok: boolean }> => + req( + "/auth/password-reset/check", + { method: "POST", body: JSON.stringify({ token }) }, + { quiet: true }, + ), confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => req( "/auth/password-reset/confirm", diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 952229c6..819d5cb8 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -31,7 +31,10 @@ function localizeDetail(d: unknown): string { // `loc` field names are English, snake_case internal tokens — surface a clean, fully localized // "check your input" message rather than leaking them into a translated sentence. if (Array.isArray(d)) return t("errors.validation"); - const o = d as { code?: string; reason?: string; limit?: number } | null | undefined; + const o = d as + | { code?: string; reason?: string; limit?: number; min?: number } + | null + | undefined; if (o?.code === "quota") { if (o.reason === "max_bytes") { const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( @@ -52,6 +55,13 @@ function localizeDetail(d: unknown): string { // a raw key, which is what an i18n miss would otherwise put on screen. if (o?.code === "login") return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError")); + // The two email-link confirmations. Their failure is one a user meets by simply clicking an old + // link, so it is the last place that may answer in hardcoded English under a bilingual UI. + if (o?.code === "verify") return t("welcome.auth.verifyInvalid"); + if (o?.code === "reset") + return o.reason === "weak_password" + ? t("welcome.auth.weakPassword", { min: o.limit ?? o.min }) + : t("welcome.auth.deadLink.short"); // Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code. if (o?.code === "upload_too_large") return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });