fix(auth): close the three leftovers E9.5 logged but kept out of its diff
DX-17, three one-liners the epic's canonical sweep did not reach:
`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.
`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.
`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.
DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".
Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.
Gate: vitest 671 -> 676, pytest 924 -> 925.
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { HttpError } from "./api/core";
|
||||
import { clearEntryUrl, entryHash } from "./entryUrl";
|
||||
|
||||
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
|
||||
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
|
||||
* error — the user has to set a new one. */
|
||||
type VerifyOutcome = "ok" | "ok_reset" | "invalid";
|
||||
* error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from
|
||||
* `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling
|
||||
* someone their link is spent when the server was merely restarting sends them to request a fresh one
|
||||
* on the strength of a false statement. The same rule is already written one file away, for the
|
||||
* reset-link probe: "a network blip must never be cached as 'this link is dead'". */
|
||||
type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable";
|
||||
|
||||
export type VerifyEntry = {
|
||||
outcome: VerifyOutcome | null;
|
||||
/** The address that was confirmed, when the server named it. */
|
||||
email: string | null;
|
||||
/** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card
|
||||
* consumes a reset token, so nothing here can act on it — but silence is what made the verify half
|
||||
* a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately
|
||||
* left in the address bar (E9.5 review round 4), so signing out still recovers it. */
|
||||
resetPending: boolean;
|
||||
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
|
||||
dismiss: () => void;
|
||||
};
|
||||
@@ -38,12 +48,18 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
const [token, setToken] = useState<string | null>(() => entryHash.get("verify"));
|
||||
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [resetPending, setResetPending] = useState<boolean>(() => !!entryHash.get("reset"));
|
||||
const consumed = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const next = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("verify");
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const next = hash.get("verify");
|
||||
if (next) setToken(next);
|
||||
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
|
||||
// same-document navigation the import-time snapshot never sees. The verify half got this
|
||||
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
|
||||
if (hash.get("reset")) setResetPending(true);
|
||||
};
|
||||
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
||||
// feed itself.
|
||||
@@ -67,8 +83,27 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
|
||||
setEmail(r.email ?? null);
|
||||
})
|
||||
.catch(() => setOutcome("invalid"));
|
||||
// Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent
|
||||
// or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped
|
||||
// connection (no HttpError at all), says nothing about the token and must not be reported as
|
||||
// if it did. The token itself is unspent in that case — but the fragment has already been
|
||||
// cleared, so the honest message is "we could not reach the server", not "request a new link".
|
||||
.catch((e: unknown) => {
|
||||
const spent =
|
||||
e instanceof HttpError &&
|
||||
e.status === 400 &&
|
||||
(e.detailData as { code?: string } | undefined)?.code === "verify";
|
||||
setOutcome(spent ? "invalid" : "unreachable");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return { outcome, email, dismiss: () => setOutcome(null) };
|
||||
return {
|
||||
outcome,
|
||||
email,
|
||||
resetPending,
|
||||
dismiss: () => {
|
||||
setOutcome(null);
|
||||
setResetPending(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user