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:
parent
59de0ffdfd
commit
d560825685
15 changed files with 139 additions and 78 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue