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);
},
};
}