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:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user