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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user