fix(auth): a Google connect names its tab's account too, and a refused tab says why

- /auth/google/connect is a navigation and carries no headers: the tab names the account it
  was built for in `expect`, checked by the same comparison as the request header
  (built_for_another_account); a mismatch lands on /?account=changed
- the link marker names the OAuth state of its own flow: an older flow coming back no longer
  spends the marker of the connect still open (which then returned as a plain sign-in)
- a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it
  now shows the account signed in
- a refused switch to a suspended or not-yet-approved account says so in the signed-in
  app's words, naming the address a request was recorded for
- the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring
  to the account does not hold the save up; a test pins the lock
- giving up a lost page closes a channel page open over it
- e2e README names the chromium-only specs
This commit is contained in:
2026-10-21 02:05:33 +02:00
parent 94418a087e
commit ef99f7b5c7
19 changed files with 487 additions and 121 deletions
+50 -11
View File
@@ -12,7 +12,7 @@
// 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) ride the query string.
// with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications";
@@ -24,7 +24,31 @@ export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/,
* 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"] as const;
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 `${window.location.pathname}?${params.toString()}${window.location.hash}`;
}
/** 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
@@ -61,18 +85,33 @@ export function clearEntryUrl(): void {
);
}
/** What a sign-in as someone else that did not happen says to the account still signed in here. 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. A request recorded on the way says so. */
export function refusedSwitchNotice(
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,
): { level: NotifLevel; key: string } | null {
): AccountNotice | null {
if (search.get("account") === "changed")
return { level: "info", key: "header.account.tabSwitched" };
if (search.get("login") === "suspended")
return { level: "warning", key: "welcome.auth.suspended" };
return { level: "warning", key: "header.account.switchSuspended" };
const access = hash.get("access");
if (access === "requested") return { level: "info", key: "welcome.auth.accessRequested" };
if (access === "denied") return { level: "warning", key: "header.account.switchNotApproved" };
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;
}