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