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

@ -26,7 +26,18 @@ import {
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { accountKey, LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage";
import {
accountKey,
getAccountRaw,
LS,
readAccount,
readJSON,
readMerged,
setAccountRaw,
useAccountPersistedState,
writeAccount,
writeJSON,
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome";
@ -89,7 +100,7 @@ type EditablePrefs = {
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY);
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
@ -130,13 +141,13 @@ export default function App() {
);
const [view, setView] = useState<"grid" | "list">("grid");
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: localStorage.getItem(PERF_KEY) === "1",
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
}));
@ -193,7 +204,7 @@ export default function App() {
}, []);
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
@ -206,7 +217,7 @@ export default function App() {
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Bumped to tell the channel manager to drop a stale column filter when we send the user
@ -245,7 +256,7 @@ export default function App() {
const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
localStorage.setItem(PAGE_KEY, next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL).
@ -279,19 +290,19 @@ export default function App() {
// user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.navCollapsed, false))
Boolean(readAccount(LS.navCollapsed, false))
);
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.filterCollapsed, false))
Boolean(readAccount(LS.filterCollapsed, false))
);
function setNavCollapsed(next: boolean) {
setNavCollapsedState(next);
writeJSON(LS.navCollapsed, next);
writeAccount(LS.navCollapsed, next);
api.savePrefs({ navCollapsed: next }).catch(() => {});
}
function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next);
writeJSON(LS.filterCollapsed, next);
writeAccount(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {});
}
@ -301,7 +312,7 @@ export default function App() {
// the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
localStorage.setItem(PERF_KEY, perf ? "1" : "0");
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
@ -348,12 +359,12 @@ export default function App() {
if (!ok) return;
discardRef.current();
setPageState(p);
localStorage.setItem(PAGE_KEY, p);
setAccountRaw(PAGE_KEY, p);
});
return;
}
setPageState(p);
localStorage.setItem(PAGE_KEY, p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null);
@ -444,7 +455,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
localStorage.setItem(LS.settingsTab, "account");
setAccountRaw(LS.settingsTab, "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
@ -472,7 +483,7 @@ export default function App() {
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: localStorage.getItem(PERF_KEY) === "1",
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
@ -493,11 +504,11 @@ export default function App() {
}
if (typeof prefs.navCollapsed === "boolean") {
setNavCollapsedState(prefs.navCollapsed);
writeJSON(LS.navCollapsed, prefs.navCollapsed);
writeAccount(LS.navCollapsed, prefs.navCollapsed);
}
if (typeof prefs.filterCollapsed === "boolean") {
setFilterCollapsedState(prefs.filterCollapsed);
writeJSON(LS.filterCollapsed, prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the