diff --git a/frontend/src/lib/entryUrl.ts b/frontend/src/lib/entryUrl.ts index 5c803157..1614828d 100644 --- a/frontend/src/lib/entryUrl.ts +++ b/frontend/src/lib/entryUrl.ts @@ -39,12 +39,22 @@ export function clearEntryUrl(): void { const params = new URLSearchParams(window.location.search); const dropped = ONE_TIME_QUERY.filter((k) => params.has(k)); for (const k of dropped) params.delete(k); + // A `#reset=` token that nothing has spent STAYS. Only the signed-out auth card consumes it, so in + // a browser that already holds a session nobody reads it — and erasing it there turned the link + // into a dead end with no explanation: the page loads, nothing happens, and clicking the link + // again does the same, because the token is untouched and the session is still there. Leaving it + // in the address bar keeps the recovery `main` had, where this strip lived inside the auth card: + // sign out (which reloads, fragment intact) and the reset form is waiting. Once the card has used + // it the token is spent, so a later reload meets S4's named dead-link screen — which is true. + const hash = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const keep = hash.has("reset") ? window.location.hash : ""; if (!dropped.length && !window.location.hash) return; + if (!dropped.length && keep) return; const qs = params.toString(); window.history.replaceState( // Preserved, not nulled: it carries scroll restoration and the channel entry. window.history.state, "", - `${window.location.pathname}${qs ? `?${qs}` : ""}`, + `${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`, ); } diff --git a/frontend/src/lib/useVerifyEntry.ts b/frontend/src/lib/useVerifyEntry.ts index b8871f5d..11c06648 100644 --- a/frontend/src/lib/useVerifyEntry.ts +++ b/frontend/src/lib/useVerifyEntry.ts @@ -52,8 +52,9 @@ export function useVerifyEntry(): VerifyEntry { }, []); 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. + // Clear the address bar even with no token to consume, so a spent one leaves no trace on a + // reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a + // signed-in tree reads it, so erasing it there is a dead end rather than a cleanup. clearEntryUrl(); if (!token || consumed.current.has(token)) return; consumed.current.add(token); diff --git a/frontend/src/lib/verifyEntry.dom.test.tsx b/frontend/src/lib/verifyEntry.dom.test.tsx index e86cfdea..00d04db3 100644 --- a/frontend/src/lib/verifyEntry.dom.test.tsx +++ b/frontend/src/lib/verifyEntry.dom.test.tsx @@ -75,15 +75,28 @@ describe("confirming an emailed verification link", () => { expect(outcome()).toBe("invalid:-"); }); - it("clears the address bar even with nothing to confirm, so a spent token leaves no trace", async () => { + it("clears the one-time query flags it owns even with nothing to confirm", async () => { const verifyEmail = vi.fn(); - const outcome = await mountAt("/?access=requested#reset=spent", verifyEmail); + const outcome = await mountAt("/?access=requested", verifyEmail); expect(verifyEmail).not.toHaveBeenCalled(); expect(outcome()).toBe("none:-"); expect(window.location.search).toBe(""); - expect(window.location.hash).toBe(""); + }); + + // Only the signed-out auth card consumes a `#reset=` token, so in a browser that already holds a + // session nobody reads it — and erasing it turned the link into a dead end with no explanation: + // the page loads, nothing happens, and clicking it again does the same, because the token is + // untouched and the session is still there. Leaving it keeps the recovery: signing out reloads + // with the fragment intact and the reset form is waiting. + it("leaves an unspent reset link in the address bar", async () => { + const verifyEmail = vi.fn(); + + await mountAt("/?login=out#reset=unspent", verifyEmail); + + expect(window.location.hash).toBe("#reset=unspent"); + expect(window.location.search).toBe(""); // the flag it does own still goes }); // This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the