fix(state): scope all per-account localStorage by account id

Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:

- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
  Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
  collapse) — now the cache is per-account too, so there's no flash of the other account's value
  on login.

Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
This commit is contained in:
npeter83 2026-07-02 01:45:16 +02:00
parent 59de0ffdfd
commit d560825685
15 changed files with 139 additions and 78 deletions

View file

@ -1,6 +1,7 @@
import { notify, remove } from "./notifications";
import i18n from "../i18n";
import { reportError } from "./errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
export interface Me {
id: number;
@ -270,19 +271,10 @@ const setupHeaders = (token: string) => ({
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function getActiveAccount(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
export const getActiveAccount = activeAccountId;
export function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));

View file

@ -2,14 +2,10 @@
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { createStore } from "./store";
import { LS } from "./storage";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
function load(): boolean {
try {
return localStorage.getItem(LS.hints) !== "0"; // default ON
} catch {
return true;
}
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
}
const store = createStore<boolean>(load());
@ -19,9 +15,5 @@ export const subscribeHints = store.subscribe;
export function setHintsEnabled(v: boolean): void {
store.set(v);
try {
localStorage.setItem(LS.hints, v ? "1" : "0");
} catch {
/* ignore */
}
setAccountRaw(LS.hints, v ? "1" : "0");
}

View file

@ -2,7 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { LS, readJSON, readMerged, writeJSON } from "./storage";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@ -87,7 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings();
function loadSettings(): NotifSettings {
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
}
export function getNotifSettings(): NotifSettings {
@ -96,7 +96,7 @@ export function getNotifSettings(): NotifSettings {
export function configureNotifications(p: Partial<NotifSettings>): void {
config = { ...config, ...p };
writeJSON(SETTINGS_KEY, config);
writeAccount(SETTINGS_KEY, config);
}
let audioCtx: AudioContext | null = null;
@ -130,7 +130,7 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = readJSON<unknown>(HISTORY_KEY, []);
const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
// Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't
@ -147,7 +147,7 @@ function persist() {
// Transient status notices are live-session only — never write them to history,
// so a reload can't leave one stranded with no producer left to clear it.
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
writeJSON(HISTORY_KEY, slim);
writeAccount(HISTORY_KEY, slim);
} catch {
/* ignore quota / serialization errors */
}

View file

@ -12,9 +12,11 @@
// suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account).
import { LS } from "./storage";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it.
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: {
@ -28,7 +30,7 @@ export function shouldAutoOpenOnboarding(me: {
// email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
}
/** Mark a grant as in progress, then send the user to Google's consent screen. */
@ -40,5 +42,5 @@ export function beginGrant(access: "read" | "write"): void {
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void {
sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1");
if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
}

View file

@ -4,7 +4,7 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readJSON, writeJSON } from "./storage";
import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
@ -46,9 +46,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
}
export function loadLayout(): SidebarLayout {
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
}
export function saveLayoutLocal(l: SidebarLayout): void {
writeJSON(LS.sidebarLayout, l);
writeAccount(LS.sidebarLayout, l);
}

View file

@ -30,17 +30,64 @@ export const LS = {
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
// --- per-account scoping -----------------------------------------------------------------
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated
// here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the
// low-level helpers below.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
/** Suffix a base localStorage key with an account id. Per-account UI state (the feed filters and
* the default-view mirror) must NOT be shared across the accounts signed into one browser a
* bare shared key leaks one account's view into another (and, with per-tab accounts, two tabs
* would fight over it). Returns null when the account isn't 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): string | null {
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/** 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;
}
/** 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). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
if (k) {
try {
localStorage.setItem(k, value);
} catch {
/* ignore */
}
}
}
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
* falling back to `defaults` on missing or corrupt data. */
export function readMerged<T extends object>(key: string, defaults: T): T {
@ -91,3 +138,18 @@ export function usePersistedState(
};
return [value, set];
}
/** Like usePersistedState but 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. These hooks run
* in components that only mount once signed in, so the account is known. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const set = (next: string) => {
setValue(next);
setAccountRaw(base, next);
};
return [value, set];
}

View file

@ -1,4 +1,4 @@
import { LS, readMerged, writeJSON } from "./storage";
import { LS, readAccountMerged, writeAccount } from "./storage";
export type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
@ -30,9 +30,9 @@ export function applyTheme(t: ThemePrefs): void {
}
export function loadLocalTheme(): ThemePrefs {
return readMerged(LS.theme, DEFAULT_THEME);
return readAccountMerged(LS.theme, DEFAULT_THEME);
}
export function saveLocalTheme(t: ThemePrefs): void {
writeJSON(LS.theme, t);
writeAccount(LS.theme, t);
}