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) => {