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.
This commit is contained in:
2026-09-03 23:57:18 +02:00
parent c134b514c1
commit e5997ca567
2 changed files with 76 additions and 6 deletions
+27 -6
View File
@@ -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<string | null>(() => entryHash.get("verify"));
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
const [email, setEmail] = useState<string | null>(null);
const fired = useRef(false);
const consumed = useRef(new Set<string>());
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) => {
+49
View File
@@ -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);
});
});