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.
|
* 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.
|
* 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)
|
* The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL
|
||||||
* would 400 and wrongly flip a successful confirmation to "invalid".
|
* 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 {
|
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 [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
||||||
const [email, setEmail] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
// Clear the address bar even with no token to consume: a `#reset=` fragment opened in a
|
// 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.
|
// signed-in browser is equally spent, and leaving either in history re-sends it on a reload.
|
||||||
clearEntryUrl();
|
clearEntryUrl();
|
||||||
if (!token || fired.current) return;
|
if (!token || consumed.current.has(token)) return;
|
||||||
fired.current = true;
|
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
|
api
|
||||||
.verifyEmail(token)
|
.verifyEmail(token)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
|||||||
@@ -85,3 +85,52 @@ describe("confirming an emailed verification link", () => {
|
|||||||
expect(window.location.hash).toBe("");
|
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