Every address-bar writer glued `pathname + query`, so a path of `//evil.com/x` named another host: history refused it (SecurityError) and the first nav mirror threw the app into its crash screen. One builder, `hrefHere`, now makes the address from the URL itself and stays on this origin.
118 lines
7.0 KiB
TypeScript
118 lines
7.0 KiB
TypeScript
// 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
|
|
// <App>, 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<string, string>;
|
|
}
|
|
|
|
/** 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;
|
|
}
|