From d560825685b5c02d8aa6d0850e14f6d94bbee519 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 2 Jul 2026 01:45:16 +0200 Subject: [PATCH] fix(state): scope all per-account localStorage by account id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 47 +++++++----- frontend/src/components/AdminUsers.tsx | 4 +- frontend/src/components/ChannelDiscovery.tsx | 3 +- frontend/src/components/Channels.tsx | 3 +- frontend/src/components/Playlists.tsx | 10 +-- frontend/src/components/SettingsPanel.tsx | 4 +- frontend/src/components/Stats.tsx | 4 +- frontend/src/components/Tabs.tsx | 8 +-- frontend/src/lib/api.ts | 14 +--- frontend/src/lib/hints.ts | 14 +--- frontend/src/lib/notifications.ts | 10 +-- frontend/src/lib/onboarding.ts | 8 ++- frontend/src/lib/sidebarLayout.ts | 6 +- frontend/src/lib/storage.ts | 76 ++++++++++++++++++-- frontend/src/lib/theme.ts | 6 +- 15 files changed, 139 insertions(+), 78 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72a74cf..e05e41e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => getNotifSettings()); const [savedPrefs, setSavedPrefs] = useState(() => ({ 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(readJSON(LS.navCollapsed, false)) + Boolean(readAccount(LS.navCollapsed, false)) ); const [filterCollapsed, setFilterCollapsedState] = useState(() => - 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 diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 356b99d..9e43dec 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; -import { LS } from "../lib/storage"; +import { LS, setAccountRaw } from "../lib/storage"; import Tooltip from "./Tooltip"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; @@ -20,7 +20,7 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { - localStorage.setItem(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); + setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); } export default function AdminUsers({ me }: { me: Me }) { diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 62deae1..f51a7fe 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserPlus } from "lucide-react"; import { api, HttpError, type DiscoveredChannel } from "../lib/api"; +import { accountKey } from "../lib/storage"; import { formatViews } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -173,7 +174,7 @@ export default function ChannelDiscovery({ rows={rows} columns={columns} rowKey={(c) => c.id} - persistKey="siftlode.channelDiscoveryTable" + persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} controlsPosition="top" emptyText={t("channels.discovery.empty")} /> diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 26e4fa7..0729a4d 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -17,6 +17,7 @@ import { X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; +import { accountKey } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -569,7 +570,7 @@ export default function Channels({ rows={visibleChannels} columns={columns} rowKey={(c) => c.id} - persistKey="siftlode.channelsTable" + persistKey={accountKey("siftlode.channelsTable") ?? undefined} controlsPosition="top" controlsLeading={statusChips} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index dbdba96..5229a8d 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -37,7 +37,7 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; -import { LS, readMerged, writeJSON } from "../lib/storage"; +import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; @@ -188,18 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const confirm = useConfirm(); // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). const [selectedId, setSelectedId] = useState(() => { - const s = localStorage.getItem(LS.playlist); + const s = getAccountRaw(LS.playlist); return s ? Number(s) : null; }); useEffect(() => { - if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId)); + if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId)); }, [selectedId]); const selectedRef = useRef(null); const scrolledRef = useRef(false); // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => readMerged(LS.plSort, PL_SORT_DEFAULT)); + const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); useEffect(() => { - writeJSON(LS.plSort, plSort); + writeAccount(LS.plSort, plSort); }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 4d13622..6fc6582 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Bell, Monitor, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; -import { LS, usePersistedState } from "../lib/storage"; +import { LS, useAccountPersistedState } from "../lib/storage"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -54,7 +54,7 @@ export default function SettingsPanel({ onOpenWizard: () => void; }) { const { t } = useTranslation(); - const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance"); + const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance"); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) ? (tabRaw as TabId) diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index f1b6db6..74ff855 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -5,7 +5,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; -import { LS, usePersistedState } from "../lib/storage"; +import { LS, useAccountPersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; import { Section, SettingRow } from "./ui/form"; @@ -18,7 +18,7 @@ type StatsTab = "overview" | "system"; export default function Stats({ me }: { me: Me }) { const { t } = useTranslation(); const isAdmin = me.role === "admin"; - const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview"); + const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview"); // Clamp at render: only admins may land on the System tab (covers a stale stored value). const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 42b1387..ce707f5 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,4 +1,4 @@ -import { usePersistedState } from "../lib/storage"; +import { useAccountPersistedState } from "../lib/storage"; // Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). // The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, @@ -10,9 +10,9 @@ export interface TabDef { badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined } -/** Persisted active-tab state — the canonical helper now lives in lib/storage as - * `usePersistedState`; re-exported here under its original name for existing call sites. */ -export const usePersistedTab = usePersistedState; +/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't + * carry into another in the same browser) — re-exported under its original name for call sites. */ +export const usePersistedTab = useAccountPersistedState; export default function Tabs({ tabs, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9dee231..94d297f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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)); diff --git a/frontend/src/lib/hints.ts b/frontend/src/lib/hints.ts index b4ecaae..cd1f2aa 100644 --- a/frontend/src/lib/hints.ts +++ b/frontend/src/lib/hints.ts @@ -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(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"); } diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 80aa84b..07e4a30 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -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): 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(HISTORY_KEY, []); + const raw = readAccount(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 */ } diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index 26822c3..b30a6f8 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -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"); } diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index 5cd388d..3e0d630 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -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(LS.sidebarLayout, {})); + return normalizeLayout(readAccount(LS.sidebarLayout, {})); } export function saveLayoutLocal(l: SidebarLayout): void { - writeJSON(LS.sidebarLayout, l); + writeAccount(LS.sidebarLayout, l); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index ea661b6..7a45901 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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(base: string, fallback: T): T { + const k = accountKey(base); + return k ? readJSON(k, fallback) : fallback; +} +export function readAccountMerged(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(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(() => getAccountRaw(base) ?? fallback); + const set = (next: string) => { + setValue(next); + setAccountRaw(base, next); + }; + return [value, set]; +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index e841652..5104119 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -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); }