import { useEffect, useRef, useState } from "react"; import { api } from "./api"; import { HttpError } from "./api/core"; import { clearEntryUrl, entryHash } from "./entryUrl"; /** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this * account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials * error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from * `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling * someone their link is spent when the server was merely restarting sends them to request a fresh one * on the strength of a false statement. The same rule is already written one file away, for the * reset-link probe: "a network blip must never be cached as 'this link is dead'". */ type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable"; export type VerifyEntry = { outcome: VerifyOutcome | null; /** The address that was confirmed, when the server named it. */ email: string | null; /** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card * consumes a reset token, so nothing here can act on it — but silence is what made the verify half * a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately * left in the address bar (E9.5 review round 4), so signing out still recovers it. */ resetPending: boolean; /** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */ dismiss: () => void; }; /** * Confirm a `#verify=` token from the entry URL, wherever the user opened it. * * App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be * consumed inside Welcome.tsx, and `App.tsx` renders only when there is no session. Open * the emailed link in a browser that already holds a Siftlode session and the token was never posted, * 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. * * 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, setToken] = useState(() => entryHash.get("verify")); const [outcome, setOutcome] = useState(null); const [email, setEmail] = useState(null); const [resetPending, setResetPending] = useState(() => !!entryHash.get("reset")); const consumed = useRef(new Set()); useEffect(() => { const onHashChange = () => { const hash = new URLSearchParams(window.location.hash.replace(/^#/, "")); const next = hash.get("verify"); if (next) setToken(next); // Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a // same-document navigation the import-time snapshot never sees. The verify half got this // listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17). if (hash.get("reset")) { setResetPending(true); // Same rule as a second verify link below: a newly pasted link must not be shadowed by a // banner still standing from the previous one. Without this the reset guidance is // unreachable whenever an unread verify outcome is on screen, and `dismiss` then clears the // pending flag along with the outcome — the DX-17 silence, one state later. setOutcome(null); setEmail(null); } }; // 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, 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); // A second link arriving over an unread first outcome must not leave the OLD banner standing — // whichever kind that outcome was. Clearing both keeps the two states mutually exclusive, which // is what lets the single `dismiss` below clear "whatever is showing" without eating the other. setOutcome(null); setEmail(null); setResetPending(false); api .verifyEmail(token) .then((r) => { setOutcome(r.password_reset_required ? "ok_reset" : "ok"); setEmail(r.email ?? null); }) // Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent // or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped // connection (no HttpError at all), says nothing about the token and must not be reported as // if it did. The token itself is unspent in that case — but the fragment has already been // cleared, so the honest message is "we could not reach the server", not "request a new link". .catch((e: unknown) => { const spent = e instanceof HttpError && e.status === 400 && (e.detailData as { code?: string } | undefined)?.code === "verify"; setOutcome(spent ? "invalid" : "unreachable"); }); }, [token]); return { outcome, email, resetPending, dismiss: () => { setOutcome(null); setResetPending(false); }, }; }