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, 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 first; the reset half was left out, so it stayed silent (DX-17). // // Set from the live hash rather than one-way, because the flag must not outlive the token it // describes: navigating away from a `#reset=` (a pasted verify link, the back button) leaves // the banner promising that signing out will resume a reset whose token is gone — and, being // checked first, hiding whatever arrived in its place. `clearEntryUrl`'s own replaceState // fires no hashchange, so this cannot undo the deliberate keep-the-reset-fragment behaviour. setResetPending(!!hash.get("reset")); }; // 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. setOutcome(null); setEmail(null); 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 peels ONE layer. The two states are independent — a reset link can arrive over an // unread confirmation, and a slow /auth/verify can resolve after one did — so clearing both at // once silently discards whichever the user had not read yet. The banner shows the reset // guidance first (it is the one with something to do), and dismissing it reveals the outcome // underneath rather than taking it with it. dismiss: () => { if (resetPending) { setResetPending(false); return; } setOutcome(null); setEmail(null); }, }; }