fix(auth): stop erasing a reset link the signed-in tree never reads (E9.5 review round 4)
Round 4 came back converged — nothing above low, and both findings in one family. This is the half worth fixing, because the branch introduced it. Only the signed-out auth card consumes a `#reset=` token, and S2 moved the address-bar cleanup up to a hook App mounts unconditionally. So opening a password-reset link in a browser that already holds a session erased the token from the URL while nothing had read it: the page loads, nothing is said, and clicking the link again does exactly the same, because the token is untouched and the session is still there. That is the dead end this epic exists to remove, and `main` did not have it — the strip used to live inside the card, which a signed-in visitor never renders. An unspent reset fragment now stays, which restores the recovery that shape always had: signing out reloads with the fragment intact and the form is waiting. Once the card has spent the token a later reload meets S4's named dead-link screen, which is true. The affirmative version — a banner telling a signed-in visitor that reset links open in a signed-out window — is logged with DX-15, along with the `hashchange` half that still only recognises `#verify=`.
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user