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
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue