From e5997ca567d95536cd028e04ff5ab9537250d598 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 3 Sep 2026 23:57:18 +0200 Subject: [PATCH] fix(auth): consume a verification token that arrives without a page load (E9.5 S2) Found in UAT. Navigating a tab that already runs the app to a URL differing only in the fragment is a same-document navigation: nothing reloads, so the import-time entry snapshot never sees the token. It sat unconsumed in the address bar and the previous outcome stayed on screen -- the same silence this sprint set out to remove, wearing a different hat. Pasting the emailed link into a running tab does exactly this, and our verification mails were plain text until 0.78.8, which makes pasting the normal way to use one. A hashchange listener now picks up the new token, the guard tracks consumed tokens rather than a single fired flag (still exactly one POST per token), and a fresh link clears the standing outcome instead of leaving the old banner up. --- frontend/src/lib/useVerifyEntry.ts | 33 ++++++++++++--- frontend/src/lib/verifyEntry.dom.test.tsx | 49 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/useVerifyEntry.ts b/frontend/src/lib/useVerifyEntry.ts index c67ddf37..b8871f5d 100644 --- a/frontend/src/lib/useVerifyEntry.ts +++ b/frontend/src/lib/useVerifyEntry.ts @@ -24,21 +24,42 @@ export type VerifyEntry = { * the fragment was then stripped, and the user got no signal at all — the link simply did nothing. * Incognito worked, which is why it survived so long. * - * Fires exactly once. The token is single-use, so a second POST (a StrictMode remount, any re-render) - * would 400 and wrongly flip a successful confirmation to "invalid". + * The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL + * differs only in the fragment, the browser performs a same-document navigation — no reload, so the + * import-time snapshot never sees the new token. Pasting the emailed link into a running tab's address + * bar does exactly that, and our verification mails were plain text until 0.78.8, which makes pasting + * the normal way to use one. Without the `hashchange` listener below the token would sit unconsumed in + * the address bar: the same silence signed-in users met before, in a second disguise. + * + * Each token is posted exactly once. It is single-use, so a repeat POST (a StrictMode remount, any + * re-render) would 400 and wrongly flip a successful confirmation to "invalid". */ export function useVerifyEntry(): VerifyEntry { - const token = entryHash.get("verify"); + const [token, setToken] = useState(() => entryHash.get("verify")); const [outcome, setOutcome] = useState(null); const [email, setEmail] = useState(null); - const fired = useRef(false); + const consumed = useRef(new Set()); + + useEffect(() => { + const onHashChange = () => { + const next = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("verify"); + if (next) setToken(next); + }; + // replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot + // feed itself. + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); useEffect(() => { // Clear the address bar even with no token to consume: a `#reset=` fragment opened in a // signed-in browser is equally spent, and leaving either in history re-sends it on a reload. clearEntryUrl(); - if (!token || fired.current) return; - fired.current = true; + if (!token || consumed.current.has(token)) return; + consumed.current.add(token); + // A second link arriving over an unread first outcome must not leave the OLD banner standing. + setOutcome(null); + setEmail(null); api .verifyEmail(token) .then((r) => { diff --git a/frontend/src/lib/verifyEntry.dom.test.tsx b/frontend/src/lib/verifyEntry.dom.test.tsx index e796300a..0525a605 100644 --- a/frontend/src/lib/verifyEntry.dom.test.tsx +++ b/frontend/src/lib/verifyEntry.dom.test.tsx @@ -85,3 +85,52 @@ describe("confirming an emailed verification link", () => { expect(window.location.hash).toBe(""); }); }); + +// The link does not always arrive as a page LOAD. Found in UAT on 2026-09-03: navigating a tab that +// already runs the app to a URL differing only in the fragment is a same-document navigation, so +// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the +// address bar — the same silence the sprint set out to remove, wearing a different hat. +describe("a verification link that arrives as a fragment-only navigation", () => { + it("consumes a token delivered by hashchange, and clears it from the address bar", async () => { + const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" }); + const outcome = await mountAt("/", verifyEmail); + expect(verifyEmail).not.toHaveBeenCalled(); + + await act(async () => { + window.history.replaceState(null, "", "/#verify=pasted-token"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + expect(verifyEmail).toHaveBeenCalledWith("pasted-token"); + expect(outcome()).toBe("ok:pasted@example.com"); + expect(window.location.hash).toBe(""); + }); + + it("replaces a standing outcome rather than leaving the previous banner up", async () => { + const verifyEmail = vi + .fn() + .mockResolvedValueOnce({ ok: true, email: "first@example.com" }) + .mockRejectedValueOnce(new Error("400")); + const outcome = await mountAt("/#verify=first-token", verifyEmail); + expect(outcome()).toBe("ok:first@example.com"); + + await act(async () => { + window.history.replaceState(null, "", "/#verify=spent-token"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + expect(outcome()).toBe("invalid:-"); + }); + + it("posts a given token once even if the same fragment arrives again", async () => { + const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" }); + await mountAt("/#verify=same-token", verifyEmail); + + await act(async () => { + window.history.replaceState(null, "", "/#verify=same-token"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + expect(verifyEmail).toHaveBeenCalledTimes(1); + }); +});