fix(auth): a tab acts only as the account it was built for, and the review's other findings
A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
+27
-10
@@ -3,8 +3,11 @@ import { useCallback, useState } from "react";
|
||||
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||||
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||||
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
|
||||
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
|
||||
// (storageKinds.contract.test.ts).
|
||||
// declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
|
||||
// reading of the source can: no key is spelled out (or handed to the Storage API as a literal)
|
||||
// anywhere else, a `pref` is read and written only through usePref or the one store listed as
|
||||
// keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does
|
||||
// with its key is the reviewer's to check.
|
||||
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
|
||||
// follows the account to every device — usePref, whose local copy only carries a value
|
||||
// this browser held before the server did.
|
||||
@@ -17,11 +20,12 @@ export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
|
||||
|
||||
const KEYS = {
|
||||
theme: { key: "siftlode.theme", kind: "pref" },
|
||||
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
|
||||
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
|
||||
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
|
||||
// A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
|
||||
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
|
||||
plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
|
||||
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
|
||||
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
|
||||
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
|
||||
filesLayout: { key: "siftlode.filesLayout", kind: "pref" },
|
||||
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
|
||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||
filesView: { key: "siftlode.filesView", kind: "view" },
|
||||
@@ -107,10 +111,12 @@ const KEYS = {
|
||||
// reason as above: the viewer has no account to keep it in.
|
||||
watchResume: { key: "siftlode.watchResume", kind: "public" },
|
||||
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
|
||||
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
|
||||
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and
|
||||
// when the tab last reloaded onto a newer build (`lib/staleBuild.ts`).
|
||||
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
|
||||
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
|
||||
logTab: { key: "siftlode.logTab", kind: "device" },
|
||||
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
|
||||
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
|
||||
|
||||
/** Every key the app stores, by name. */
|
||||
@@ -178,12 +184,16 @@ function settleStore(store: Storage, id: string, wipe: boolean): void {
|
||||
export function claimLocalData(id: number): void {
|
||||
const me = String(id);
|
||||
try {
|
||||
// Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the
|
||||
// account another tab already claimed the browser for still holds the previous account's. A
|
||||
// tab that records no owner (one a build before this opened) goes by the browser's.
|
||||
const owner = localStorage.getItem(OWNER_KEY);
|
||||
const wipe = owner != null && owner !== me;
|
||||
settleStore(localStorage, me, wipe);
|
||||
settleStore(sessionStorage, me, wipe);
|
||||
const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
|
||||
settleStore(localStorage, me, owner != null && owner !== me);
|
||||
settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
|
||||
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
|
||||
localStorage.setItem(OWNER_KEY, me);
|
||||
sessionStorage.setItem(OWNER_KEY, me);
|
||||
} catch {
|
||||
/* storage unavailable: nothing to settle */
|
||||
}
|
||||
@@ -191,6 +201,13 @@ export function claimLocalData(id: number): void {
|
||||
claimListeners.forEach((reread) => reread());
|
||||
}
|
||||
|
||||
/** The account this tab was built for (claimed at boot), as its id; null before the claim. Every
|
||||
* request says so (`identityHeaders`): the server refuses one from a tab whose browser has since
|
||||
* signed in as someone else, and the tab reloads as whoever is signed in now. */
|
||||
export function claimedAccount(): string | null {
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
|
||||
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
|
||||
export function ownKey(base: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user