fix(auth): stop drawing a reset form the link can no longer fill (E9.5 S4)

Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.

password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.

A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.

Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.

One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.

UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
This commit is contained in:
2026-09-04 02:38:01 +02:00
parent ed91c83dd7
commit b1086e05dc
9 changed files with 400 additions and 33 deletions
+48 -5
View File
@@ -50,6 +50,8 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_register_limiter = RateLimiter(max_events=5, window_seconds=300) _register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, 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) _verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
_request_access_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) _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 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: 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 """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 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.""" flag is rare now — but it must stay, because those users have no other way back in."""
user = _consume_token(db, body.token, "verify") user = _consume_token(db, body.token, "verify")
if user is None: if user is None:
raise HTTPException( raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
status_code=400, detail="This verification link is invalid or has expired."
)
user.email_verified = True user.email_verified = True
needs_password = user.password_hash is None and user.password_revoked_at is not None needs_password = user.password_hash is None and user.password_revoked_at is not None
email = user.email 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}") 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") @router.post("/password-reset/confirm")
def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict: 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.""" """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: if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException( raise HTTPException(
status_code=400, 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") user = _consume_token(db, token, "reset")
if user is None: 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_hash = hash_password(new_password)
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent 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 # A valid reset link proves control of the mailbox, so completing a reset also verifies the
+90
View File
@@ -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}
+130 -25
View File
@@ -11,6 +11,7 @@ import {
Expand, Expand,
Filter, Filter,
Inbox, Inbox,
Link2Off,
ListVideo, ListVideo,
Lock, Lock,
MailWarning, MailWarning,
@@ -23,7 +24,7 @@ import {
// Siftlode is open source; the landing footer links to the public repository. // Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode"; 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 { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n"; import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher"; 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<string, Promise<boolean>>();
function checkResetToken(token: string): Promise<boolean> {
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 }) { function AuthCard({ verify }: { verify: VerifyEntry }) {
const { t } = useTranslation(); const { t } = useTranslation();
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can // 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 // (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. // correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
const [blocked, setBlocked] = useState<LoginBlocked | null>(null); const [blocked, setBlocked] = useState<LoginBlocked | null>(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` // `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 // — 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. // 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(""); setDone("");
setError(""); setError("");
setBlocked(null); setBlocked(null);
setDeadReset(false);
}, [mode]); }, [mode]);
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated // Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
@@ -375,6 +426,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setError(""); setError("");
setDone(""); setDone("");
setBlocked(null); setBlocked(null);
setDeadReset(false);
// The verify/login banners are one-time entry notices — dismiss once the user takes any // 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. // deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
verify.dismiss(); verify.dismiss();
@@ -438,6 +490,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setBlocked(refusal); setBlocked(refusal);
return; 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( setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"), 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]; // A named state owns the card's heading while it is up, so it reads as the card's subject rather
const BlockedIcon = blocked ? BLOCKED_ICON[blocked.reason] : null; // 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 ( return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end"> <div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5"> <h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{BlockedIcon && ( {StateIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0"> <span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
<BlockedIcon className="w-[18px] h-[18px]" /> <StateIcon className="w-[18px] h-[18px]" />
</span> </span>
)} )}
{t(titleKey)} {t(titleKey)}
@@ -527,6 +592,19 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
{blocked ? ( {blocked ? (
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} /> <BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
) : deadReset ? (
<StatePanel
body={t("welcome.auth.deadLink.body")}
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
onBack={() => {
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.
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
) : done ? ( ) : done ? (
<div className="mt-3"> <div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p> <p className="text-sm text-fg/80 leading-relaxed">{done}</p>
@@ -698,6 +776,43 @@ const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
suspended: ShieldAlert, 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 (
<div className="mt-3 flex flex-col gap-4">
<p className="text-sm text-fg/80 leading-relaxed">{body}</p>
{children}
<div className="flex items-center justify-between gap-3">
<button onClick={onBack} className="text-sm text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
{cta && (
<button
onClick={cta.onClick}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
{cta.label}
</button>
)}
</div>
</div>
);
}
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the // 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" // 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 // 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. // Suspension is not a gate that will open on its own, so the checklist would be a lie there.
const gated = reason !== "suspended"; const gated = reason !== "suspended";
return ( return (
<div className="mt-3 flex flex-col gap-4"> <StatePanel
<p className="text-sm text-fg/80 leading-relaxed"> body={t(`welcome.auth.blocked.${reason}.body`)}
{t(`welcome.auth.blocked.${reason}.body`)} cta={
</p> reason === "email_unverified"
? { label: t("welcome.auth.resendCta"), onClick: onResend }
: undefined
}
onBack={onBack}
>
{gated && ( {gated && (
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2"> <div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
<Gate done={emailVerified === true} k="email" /> <Gate done={emailVerified === true} k="email" />
@@ -744,21 +863,7 @@ function BlockedPanel({
{t("welcome.auth.blocked.operator", { contact: operator })} {t("welcome.auth.blocked.operator", { contact: operator })}
</p> </p>
)} )}
</StatePanel>
<div className="flex items-center justify-between gap-3">
<button onClick={onBack} className="text-sm text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
{reason === "email_unverified" && (
<button
onClick={onResend}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.resendCta")}
</button>
)}
</div>
</div>
); );
} }
@@ -51,7 +51,7 @@ async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}
await act(async () => { await act(async () => {
render(<Welcome verify={noVerify} />); render(<Welcome verify={noVerify} />);
}); });
return { HttpError }; return { HttpError, Welcome };
} }
/** Fill the sign-in form and submit it. */ /** 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(<Welcome verify={noVerify} />);
});
// 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", () => { describe("the access-request notice from a denied Google sign-in", () => {
it("names the address the request was recorded for", async () => { it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com"); await mountCard("/#access=requested&email=nptemu%40gmail.com");
@@ -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.", "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.", "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.", "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.", "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.", "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", "verifyOkResetCta": "Set a new password",
@@ -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.", "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.", "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.", "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.", "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.", "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", "verifyOkResetCta": "Új jelszó beállítása",
+1 -1
View File
@@ -27,7 +27,7 @@ import { accountApi } from "./api/account";
export type * from "./api/account"; export type * from "./api/account";
import { authApi } from "./api/auth"; import { authApi } from "./api/auth";
export type * from "./api/auth"; export type * from "./api/auth";
export { loginBlocked } from "./api/auth"; export { loginBlocked, deadResetLink } from "./api/auth";
import { setupApi } from "./api/setup"; import { setupApi } from "./api/setup";
export type * from "./api/setup"; export type * from "./api/setup";
import { feedApi } from "./api/feed"; import { feedApi } from "./api/feed";
+17
View File
@@ -19,6 +19,14 @@ export type LoginBlocked = {
operator?: string; 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<LoginBlocked["reason"]>([ const LOGIN_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified", "email_unverified",
"pending_approval", "pending_approval",
@@ -72,6 +80,15 @@ export const authApi = {
{ method: "POST", body: JSON.stringify({ email }) }, { method: "POST", body: JSON.stringify({ email }) },
{ quiet: true }, { 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 }> => confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req( req(
"/auth/password-reset/confirm", "/auth/password-reset/confirm",
+11 -1
View File
@@ -31,7 +31,10 @@ function localizeDetail(d: unknown): string {
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized // `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. // "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation"); 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?.code === "quota") {
if (o.reason === "max_bytes") { if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( 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. // a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login") if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError")); 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. // 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") if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) }); return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });