refactor(frontend): one account per browser, its local state under plain keys

The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone
says who is asking. localStorage keys lose their account suffix; the browser records whose state it
holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different
account starts from a clean slate (the public /watch keys stay). The first boot moves the account's
old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and
after another tab claimed the browser for someone else, the account helpers read and write nothing.

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
This commit is contained in:
2026-10-20 15:29:41 +02:00
parent 6645caa157
commit d6dada7fe5
51 changed files with 672 additions and 559 deletions
+91 -33
View File
@@ -1,5 +1,4 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
@@ -35,8 +34,9 @@ export const LS = {
page: "siftlode.page",
// Start every session on the Home surface rather than on the module you happened to leave
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
// account is pinned — so the landing is decided right before the first paint, never corrected after.
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
// corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
@@ -96,43 +96,103 @@ export const LS = {
watchResume: "siftlode.watchResume",
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
// --- the signed-in account's local state --------------------------------------------------
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
// records whose state it holds instead (OWNER_KEY), and an account that is not the recorded one starts
// from a clean slate. The account is known only once BootGate has it from the server, and `claimLocalData`
// is how BootGate hands it over; until then the helpers below read nothing and write nothing, so the
// signed-out landing, or a boot still asking, cannot touch the state of whoever signs in next.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
* of touching another account's data. */
export function accountKey(
base: string,
id: number | null | undefined = activeAccountId(),
): string | null {
return id != null ? `${base}.${id}` : null;
const OWNER_KEY = "siftlode.owner";
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
const BROWSER_KEYS: ReadonlySet<string> = new Set([LS.watchRepeat, LS.watchResume, OWNER_KEY]);
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
// the other accounts' copies; only keys the registry knows are touched.
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
const REGISTERED: ReadonlySet<string> = new Set(Object.values(LS));
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
let claimed: string | null = null;
const claimListeners = new Set<() => void>();
/** Re-read module-level state read at import (the notification and hints stores), which runs before
* BootGate knows the account. */
export function onLocalDataClaimed(reread: () => void): void {
claimListeners.add(reread);
}
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
function each(store: Storage, visit: (key: string) => void): void {
for (const key of Object.keys(store)) visit(key);
}
function settleStore(store: Storage, id: string, wipe: boolean): void {
each(store, (key) => {
if (!key.startsWith("siftlode.")) return;
if (wipe && !BROWSER_KEYS.has(key)) {
store.removeItem(key);
return;
}
const [, base, owner] = ACCOUNT_COPY.exec(key) ?? [];
if (!base || !REGISTERED.has(base)) return;
const value = store.getItem(key);
if (owner === id && value != null) store.setItem(base, value);
store.removeItem(key);
});
}
/** Hand the browser's local state to the signed-in account `id` (BootGate, before the app mounts).
* Another account's state is wiped first; the old per-account copies are moved onto the plain keys. */
export function claimLocalData(id: number): void {
const me = String(id);
try {
const owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me;
settleStore(localStorage, me, wipe);
settleStore(sessionStorage, me, wipe);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me);
} catch {
/* storage unavailable: nothing to settle */
}
claimed = me;
claimListeners.forEach((reread) => reread());
}
/** `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 {
if (claimed == null) return null;
try {
return localStorage.getItem(OWNER_KEY) === claimed ? base : null;
} catch {
return null;
}
}
/** The account's read/write helpers: no-op / the fallback while the state is not this tab's (see ownKey). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
const k = ownKey(base);
try {
return k ? localStorage.getItem(k) : null;
} catch {
return null;
}
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) {
try {
localStorage.setItem(k, value);
@@ -176,11 +236,9 @@ export function writeJSON(key: string, value: unknown): void {
// --- reactive persisted string state ----------------------------------------------------
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
/** A `useState` whose string value is mirrored to the account's localStorage (see ownKey). Survives reload (F5); validation is deferred to the caller (clamp at render) so it
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
* mount: BootGate mounts every consumer after the account is pinned. */
* mount: BootGate mounts every consumer after the account is claimed. */
export function useAccountPersistedState(
base: string,
fallback = "",
@@ -201,13 +259,13 @@ export function useAccountPersistedState(
// --- a page's sub-tab, per browser tab ---------------------------------------------------------
// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser
// tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new
// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new
// tab opens the page on its first sub-tab (the owner's call: a position remembered forever in
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
/** The sub-tab stored for this browser tab, or null. */
function getTabRaw(base: string): string | null {
const k = accountKey(base);
const k = ownKey(base);
try {
return k ? sessionStorage.getItem(k) : null;
} catch {
@@ -217,7 +275,7 @@ function getTabRaw(base: string): string | null {
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
export function setTabRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (!k) return;
try {
sessionStorage.setItem(k, value);
@@ -233,7 +291,7 @@ export function useTabPersistedState(
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => {
// The position used to live in localStorage; a value left there would never be read again.
const old = accountKey(base);
const old = ownKey(base);
if (old) {
try {
localStorage.removeItem(old);