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";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
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 { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
|
|
@ -89,7 +100,7 @@ type EditablePrefs = {
|
||||||
function loadInitialPage(): Page {
|
function loadInitialPage(): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
if (params.has("page")) return readPage();
|
||||||
const stored = localStorage.getItem(PAGE_KEY);
|
const stored = getAccountRaw(PAGE_KEY);
|
||||||
return isPage(stored) ? stored : "feed";
|
return isPage(stored) ? stored : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,13 +141,13 @@ export default function App() {
|
||||||
);
|
);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
// 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 [hints, setHints] = useState(() => hintsEnabled());
|
||||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||||
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
||||||
theme: loadLocalTheme(),
|
theme: loadLocalTheme(),
|
||||||
view: "grid",
|
view: "grid",
|
||||||
performanceMode: localStorage.getItem(PERF_KEY) === "1",
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: hintsEnabled(),
|
hints: hintsEnabled(),
|
||||||
notifications: getNotifSettings(),
|
notifications: getNotifSettings(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -193,7 +204,7 @@ export default function App() {
|
||||||
}, []);
|
}, []);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
// 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 = (
|
const channelFilter: ChannelStatusFilter = (
|
||||||
["needs_full", "fully_synced", "hidden", "all"] as const
|
["needs_full", "fully_synced", "hidden", "all"] as const
|
||||||
).includes(channelFilterRaw as ChannelStatusFilter)
|
).includes(channelFilterRaw as ChannelStatusFilter)
|
||||||
|
|
@ -206,7 +217,7 @@ export default function App() {
|
||||||
// persisted so navigation intents that target the subscriptions table — the header's
|
// 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
|
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
||||||
// of dumping the user on whatever tab they last left open.
|
// 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 =
|
const channelsView: ChannelsView =
|
||||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// 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 = () => {
|
const go = () => {
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
setPageState(next);
|
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
|
// 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 —
|
// 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).
|
// 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
|
// 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.
|
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||||
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readJSON(LS.navCollapsed, false))
|
Boolean(readAccount(LS.navCollapsed, false))
|
||||||
);
|
);
|
||||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readJSON(LS.filterCollapsed, false))
|
Boolean(readAccount(LS.filterCollapsed, false))
|
||||||
);
|
);
|
||||||
function setNavCollapsed(next: boolean) {
|
function setNavCollapsed(next: boolean) {
|
||||||
setNavCollapsedState(next);
|
setNavCollapsedState(next);
|
||||||
writeJSON(LS.navCollapsed, next);
|
writeAccount(LS.navCollapsed, next);
|
||||||
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
||||||
}
|
}
|
||||||
function setFilterCollapsed(next: boolean) {
|
function setFilterCollapsed(next: boolean) {
|
||||||
setFilterCollapsedState(next);
|
setFilterCollapsedState(next);
|
||||||
writeJSON(LS.filterCollapsed, next);
|
writeAccount(LS.filterCollapsed, next);
|
||||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
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.
|
// the stores too); persistence to the server is deferred to an explicit Save.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||||
localStorage.setItem(PERF_KEY, perf ? "1" : "0");
|
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
||||||
}, [perf]);
|
}, [perf]);
|
||||||
useEffect(() => setHintsEnabled(hints), [hints]);
|
useEffect(() => setHintsEnabled(hints), [hints]);
|
||||||
useEffect(() => configureNotifications(notif), [notif]);
|
useEffect(() => configureNotifications(notif), [notif]);
|
||||||
|
|
@ -348,12 +359,12 @@ export default function App() {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
discardRef.current();
|
discardRef.current();
|
||||||
setPageState(p);
|
setPageState(p);
|
||||||
localStorage.setItem(PAGE_KEY, p);
|
setAccountRaw(PAGE_KEY, p);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPageState(p);
|
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
|
// 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).
|
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
setYtSearch((e.state?._yt as string) ?? null);
|
||||||
|
|
@ -444,7 +455,7 @@ export default function App() {
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void meQuery.refetch();
|
void meQuery.refetch();
|
||||||
localStorage.setItem(LS.settingsTab, "account");
|
setAccountRaw(LS.settingsTab, "account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
|
|
@ -472,7 +483,7 @@ export default function App() {
|
||||||
performanceMode:
|
performanceMode:
|
||||||
typeof prefs.performanceMode === "boolean"
|
typeof prefs.performanceMode === "boolean"
|
||||||
? prefs.performanceMode
|
? prefs.performanceMode
|
||||||
: localStorage.getItem(PERF_KEY) === "1",
|
: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||||
notifications: prefs.notifications
|
notifications: prefs.notifications
|
||||||
? { ...getNotifSettings(), ...prefs.notifications }
|
? { ...getNotifSettings(), ...prefs.notifications }
|
||||||
|
|
@ -493,11 +504,11 @@ export default function App() {
|
||||||
}
|
}
|
||||||
if (typeof prefs.navCollapsed === "boolean") {
|
if (typeof prefs.navCollapsed === "boolean") {
|
||||||
setNavCollapsedState(prefs.navCollapsed);
|
setNavCollapsedState(prefs.navCollapsed);
|
||||||
writeJSON(LS.navCollapsed, prefs.navCollapsed);
|
writeAccount(LS.navCollapsed, prefs.navCollapsed);
|
||||||
}
|
}
|
||||||
if (typeof prefs.filterCollapsed === "boolean") {
|
if (typeof prefs.filterCollapsed === "boolean") {
|
||||||
setFilterCollapsedState(prefs.filterCollapsed);
|
setFilterCollapsedState(prefs.filterCollapsed);
|
||||||
writeJSON(LS.filterCollapsed, prefs.filterCollapsed);
|
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
||||||
}
|
}
|
||||||
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
||||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||||
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { LS } from "../lib/storage";
|
import { LS, setAccountRaw } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
@ -20,7 +20,7 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||||
export const ADMIN_USERS_ACCESS_TAB = "access";
|
export const ADMIN_USERS_ACCESS_TAB = "access";
|
||||||
|
|
||||||
export function focusAccessRequestsTab(): void {
|
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 }) {
|
export default function AdminUsers({ me }: { me: Me }) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
||||||
|
import { accountKey } from "../lib/storage";
|
||||||
import { formatViews } from "../lib/format";
|
import { formatViews } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
@ -173,7 +174,7 @@ export default function ChannelDiscovery({
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey="siftlode.channelDiscoveryTable"
|
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
|
import { accountKey } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
|
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
|
@ -569,7 +570,7 @@ export default function Channels({
|
||||||
rows={visibleChannels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey="siftlode.channelsTable"
|
persistKey={accountKey("siftlode.channelsTable") ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import {
|
||||||
import { api, type Playlist, type Video } from "../lib/api";
|
import { api, type Playlist, type Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
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 { useUndoable } from "../lib/useUndoable";
|
||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
|
|
@ -188,18 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
||||||
const s = localStorage.getItem(LS.playlist);
|
const s = getAccountRaw(LS.playlist);
|
||||||
return s ? Number(s) : null;
|
return s ? Number(s) : null;
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
|
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
// How the left playlist rail is ordered (persisted).
|
// How the left playlist rail is ordered (persisted).
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
|
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeJSON(LS.plSort, plSort);
|
writeAccount(LS.plSort, plSort);
|
||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Monitor, Trash2, User } from "lucide-react";
|
import { Bell, Monitor, Trash2, User } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { LS, usePersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
@ -54,7 +54,7 @@ export default function SettingsPanel({
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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.
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||||
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||||
? (tabRaw as TabId)
|
? (tabRaw as TabId)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { LS, usePersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { Section, SettingRow } from "./ui/form";
|
import { Section, SettingRow } from "./ui/form";
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ type StatsTab = "overview" | "system";
|
||||||
export default function Stats({ me }: { me: Me }) {
|
export default function Stats({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isAdmin = me.role === "admin";
|
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).
|
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||||
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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…).
|
// 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,
|
// 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
|
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
|
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
|
||||||
* `usePersistedState`; re-exported here under its original name for existing call sites. */
|
* carry into another in the same browser) — re-exported under its original name for call sites. */
|
||||||
export const usePersistedTab = usePersistedState;
|
export const usePersistedTab = useAccountPersistedState;
|
||||||
|
|
||||||
export default function Tabs({
|
export default function Tabs({
|
||||||
tabs,
|
tabs,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { notify, remove } from "./notifications";
|
import { notify, remove } from "./notifications";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { reportError } from "./errorDialog";
|
import { reportError } from "./errorDialog";
|
||||||
|
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
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
|
// 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
|
// 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.
|
// 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_HEADER = "X-Siftlode-Account";
|
||||||
const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
|
||||||
|
|
||||||
export function getActiveAccount(): number | null {
|
export const getActiveAccount = activeAccountId;
|
||||||
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 function setActiveAccount(id: number): void {
|
export function setActiveAccount(id: number): void {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
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
|
// 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).
|
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
import { LS } from "./storage";
|
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
||||||
|
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
try {
|
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
|
||||||
return localStorage.getItem(LS.hints) !== "0"; // default ON
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = createStore<boolean>(load());
|
const store = createStore<boolean>(load());
|
||||||
|
|
@ -19,9 +15,5 @@ export const subscribeHints = store.subscribe;
|
||||||
|
|
||||||
export function setHintsEnabled(v: boolean): void {
|
export function setHintsEnabled(v: boolean): void {
|
||||||
store.set(v);
|
store.set(v);
|
||||||
try {
|
setAccountRaw(LS.hints, v ? "1" : "0");
|
||||||
localStorage.setItem(LS.hints, v ? "1" : "0");
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||||
// errors). History is persisted to localStorage so it survives reloads; action
|
// errors). History is persisted to localStorage so it survives reloads; action
|
||||||
// callbacks are live-only and dropped on reload.
|
// 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";
|
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
||||||
let config: NotifSettings = loadSettings();
|
let config: NotifSettings = loadSettings();
|
||||||
|
|
||||||
function loadSettings(): NotifSettings {
|
function loadSettings(): NotifSettings {
|
||||||
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotifSettings(): NotifSettings {
|
export function getNotifSettings(): NotifSettings {
|
||||||
|
|
@ -96,7 +96,7 @@ export function getNotifSettings(): NotifSettings {
|
||||||
|
|
||||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||||
config = { ...config, ...p };
|
config = { ...config, ...p };
|
||||||
writeJSON(SETTINGS_KEY, config);
|
writeAccount(SETTINGS_KEY, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
let audioCtx: AudioContext | null = null;
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
@ -130,7 +130,7 @@ let cachedUnread = 0;
|
||||||
|
|
||||||
function load(): Notification[] {
|
function load(): Notification[] {
|
||||||
try {
|
try {
|
||||||
const raw = readJSON<unknown>(HISTORY_KEY, []);
|
const raw = readAccount<unknown>(HISTORY_KEY, []);
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
// Restored entries are history only. Mark them dismissed so they don't
|
// Restored entries are history only. Mark them dismissed so they don't
|
||||||
// resurrect as active toasts on reload — their auto-dismiss timers aren'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,
|
// 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.
|
// 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);
|
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||||
writeJSON(HISTORY_KEY, slim);
|
writeAccount(HISTORY_KEY, slim);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / serialization errors */
|
/* ignore quota / serialization errors */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@
|
||||||
// suppresses the auto-popup on future logins (they
|
// suppresses the auto-popup on future logins (they
|
||||||
// can still reopen it from Settings → Account).
|
// 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)
|
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 const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||||
|
|
||||||
export function shouldAutoOpenOnboarding(me: {
|
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.
|
// email+password only — then there's nothing to connect, so don't nudge.
|
||||||
if (me.google_enabled === false) return false;
|
if (me.google_enabled === false) return false;
|
||||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
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. */
|
/** 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). */
|
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
||||||
export function endOnboarding(dismiss: boolean): void {
|
export function endOnboarding(dismiss: boolean): void {
|
||||||
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
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
|
// `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.
|
// 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";
|
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
|
||||||
|
|
||||||
|
|
@ -46,9 +46,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLayout(): SidebarLayout {
|
export function loadLayout(): SidebarLayout {
|
||||||
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
|
return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
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}`,
|
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
||||||
} as const;
|
} 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
|
export function activeAccountId(): number | null {
|
||||||
* the default-view mirror) must NOT be shared across the accounts signed into one browser — a
|
try {
|
||||||
* bare shared key leaks one account's view into another (and, with per-tab accounts, two tabs
|
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
|
||||||
* would fight over it). Returns null when the account isn't known yet, so callers fall back to
|
if (!raw) return null;
|
||||||
* defaults / skip persistence instead of touching another account's data. */
|
const n = Number(raw);
|
||||||
export function accountKey(base: string, id: number | null | undefined): string | null {
|
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;
|
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),
|
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
|
||||||
* falling back to `defaults` on missing or corrupt data. */
|
* falling back to `defaults` on missing or corrupt data. */
|
||||||
export function readMerged<T extends object>(key: string, defaults: T): T {
|
export function readMerged<T extends object>(key: string, defaults: T): T {
|
||||||
|
|
@ -91,3 +138,18 @@ export function usePersistedState(
|
||||||
};
|
};
|
||||||
return [value, set];
|
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 Mode = "dark" | "light";
|
||||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
||||||
|
|
@ -30,9 +30,9 @@ export function applyTheme(t: ThemePrefs): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLocalTheme(): ThemePrefs {
|
export function loadLocalTheme(): ThemePrefs {
|
||||||
return readMerged(LS.theme, DEFAULT_THEME);
|
return readAccountMerged(LS.theme, DEFAULT_THEME);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLocalTheme(t: ThemePrefs): void {
|
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