// The URL the app was OPENED with, captured once when this module is first imported — before any // component renders and before anything rewrites the address bar. // // Why a module-level snapshot rather than a `useState(() => …)` in whichever component needs it: the // 2026-09-02 incident was exactly this race. The nav-param mirrors mount ABOVE the lazily-loaded // , and their mount effect replaceState'd the fragment away before AuthCard had snapshotted it, // so the emailed verification token was gone by the time anyone looked for it. 0.78.7 fixed that by // teaching urlWithParams/clearParams to carry the hash, but that leaves the guarantee resting on // every future URL writer remembering to do the same. Reading the entry URL once, at import time, // cannot lose the race no matter who mounts first or which chunk loads when. // // Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a // Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it // names) — an email in a query string lands in every reverse-proxy access log. Plain status flags // with nothing personal in them (login, deleted, account) ride the query string. import type { NotifLevel } from "./notifications"; import { hrefHere } from "./urlState"; export const entrySearch = new URLSearchParams(window.location.search); export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, "")); /** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a * reload would re-show a notice about something that already happened (`access` only appears here * in its old shape — it rides the fragment now). Deliberately NOT `link`, * `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup * wizard, the lazily-mounted module views) do not run until several commits later. */ const ONE_TIME_QUERY = ["login", "deleted", "access", "account"] as const; /** The flag a tab reloads with when it was showing another account than the one signed in now (the * server refused it: `account_changed`), and where a navigation the server refused for that lands * (backend `ACCOUNT_CHANGED_LANDING`). A reload wipes any toast, so the reason rides the URL. */ export const ACCOUNT_CHANGED_FLAG = "account=changed"; /** This page again, carrying `ACCOUNT_CHANGED_FLAG`: the tab boots as the account signed in now and * says why, on the page it was on. */ export function accountChangedUrl(): string { const params = new URLSearchParams(window.location.search); const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string]; params.set(key, value); return hrefHere(params); } /** Reload a tab whose session the server refused (a 401). Showing another account than the one * signed in now (`account_changed`: a sign-in as someone else in another tab), it boots as that one * and says why — a Sign out that silently left someone else signed in would read as broken. Any * other refusal means the session ended: a plain reload lands on the sign-in page. */ export function reloadAfterRefusal(err: { detailData?: unknown }): void { if ((err.detailData as { reason?: string } | undefined)?.reason === "account_changed") window.location.replace(accountChangedUrl()); else window.location.reload(); } /** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get * re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a * second caller would be harmless but makes it unclear who is responsible. * * SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment * and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it * up to App pointed it at every load. It then ran on the FIRST commit — before the setup * wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs, * and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took * `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is * somebody else's to clear, and `clearParams` is how they do it. */ 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 the 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; window.history.replaceState( // Preserved, not nulled: it carries scroll restoration and the channel entry. window.history.state, "", hrefHere(params, keep), ); } export interface AccountNotice { level: NotifLevel; key: string; values?: Record; } /** What the entry URL says to the account signed in here about a switch of account. A sign-in as * someone else that did not happen: the callback answers a Google account that is not approved, or * is suspended, without replacing the session, and with the flags the sign-in page reads — the * signed-in app read none of them, so the person came back as before without a word. Its own copy, * not the sign-in page's: beside this account's avatar, "this account is suspended" would read as * this one. A request recorded on the way names the address it lives under (`linked`, when that is * not the one Google returned). And a tab that reloaded because another account signed in under it * says that it did. */ export function accountSwitchNotice( search: URLSearchParams = entrySearch, hash: URLSearchParams = entryHash, ): AccountNotice | null { if (search.get("account") === "changed") return { level: "info", key: "header.account.tabSwitched" }; if (search.get("login") === "suspended") return { level: "warning", key: "header.account.switchSuspended" }; const access = hash.get("access"); const email = hash.get("linked") || hash.get("email"); if (access === "requested" && email) return { level: "info", key: "header.account.switchRequested", values: { email } }; if (access === "requested" || access === "denied") return { level: "warning", key: "header.account.switchNotApproved" }; return null; }