From d6dada7fe5f4367eea430e80192603585ceb26eb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 20 Oct 2026 15:29:41 +0200 Subject: [PATCH] refactor(frontend): one account per browser, its local state under plain keys The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone says who is asking. localStorage keys lose their account suffix; the browser records whose state it holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different account starts from a clean slate (the public /watch keys stay). The first boot moves the account's old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and after another tab claimed the browser for someone else, the account helpers read and write nothing. The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else" (Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google sign-in is configured. --- CLAUDE.md | 4 +- frontend/e2e/global.setup.ts | 9 +- frontend/src/App.tsx | 4 +- frontend/src/components/BootGate.dom.test.tsx | 65 +++++--- frontend/src/components/BootGate.tsx | 35 ++-- frontend/src/components/ChannelDiscovery.tsx | 4 +- frontend/src/components/Channels.tsx | 4 +- .../components/ChannelsProvider.dom.test.tsx | 15 +- .../FeedFiltersProvider.dom.test.ts | 19 +-- .../FeedFiltersProvider.seed.dom.test.tsx | 6 +- .../src/components/FeedFiltersProvider.tsx | 12 +- frontend/src/components/Files.tsx | 4 +- .../src/components/FilesProvider.dom.test.tsx | 5 +- .../NavSidebar.account.dom.test.tsx | 111 +++++++++++++ frontend/src/components/NavSidebar.tsx | 89 +++-------- .../NavigationProvider.dom.test.tsx | 4 +- .../PlexPlayer.controls.dom.test.tsx | 6 +- .../components/PlexPlayer.hls.dom.test.tsx | 4 +- .../PlexPlayer.session.dom.test.tsx | 8 +- .../src/components/PlexProvider.dom.test.tsx | 12 +- .../src/components/PrefsProvider.dom.test.tsx | 9 +- .../SavedViewsProvider.dom.test.tsx | 9 +- .../src/components/SavedViewsProvider.tsx | 5 +- .../components/channelChipNames.dom.test.tsx | 7 +- frontend/src/components/downloadRow.tsx | 5 +- frontend/src/components/feedFiltersInit.ts | 8 +- .../ui/pressableSelectable.contract.test.ts | 2 +- frontend/src/i18n/locales/en/header.json | 3 +- frontend/src/i18n/locales/hu/header.json | 3 +- .../src/lib/accountPinnedStores.dom.test.ts | 89 ----------- frontend/src/lib/activeAccount.dom.test.ts | 37 ----- frontend/src/lib/activeAccount.ts | 78 --------- frontend/src/lib/api.ts | 4 +- frontend/src/lib/api/account.ts | 17 +- frontend/src/lib/api/core.test.ts | 11 +- frontend/src/lib/api/core.ts | 27 +--- frontend/src/lib/api/diagnostics.dom.test.ts | 5 - frontend/src/lib/api/diagnostics.ts | 13 +- frontend/src/lib/api/downloads.ts | 24 +-- frontend/src/lib/api/files.ts | 19 +-- frontend/src/lib/claimedStores.dom.test.ts | 87 ++++++++++ frontend/src/lib/hints.ts | 7 +- frontend/src/lib/localData.dom.test.ts | 151 ++++++++++++++++++ frontend/src/lib/notifications.ts | 19 +-- frontend/src/lib/onboarding.ts | 2 +- frontend/src/lib/panelLayout.dom.test.ts | 7 +- frontend/src/lib/queryKeys.ts | 1 - .../src/lib/removedFeatures.contract.test.ts | 22 +++ frontend/src/lib/storage.ts | 124 ++++++++++---- frontend/src/lib/tabState.dom.test.ts | 13 +- frontend/src/lib/useCardPager.dom.test.tsx | 3 +- 51 files changed, 672 insertions(+), 559 deletions(-) create mode 100644 frontend/src/components/NavSidebar.account.dom.test.tsx delete mode 100644 frontend/src/lib/accountPinnedStores.dom.test.ts delete mode 100644 frontend/src/lib/activeAccount.dom.test.ts delete mode 100644 frontend/src/lib/activeAccount.ts create mode 100644 frontend/src/lib/claimedStores.dom.test.ts create mode 100644 frontend/src/lib/localData.dom.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index dac7149b..da7357f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,8 +81,8 @@ boundary is one-way and **lint-enforced**, not just described: a platform compon feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md). -Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through -`lib/api/*` slices over `core.req`. +Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server +prefs; API through `lib/api/*` slices over `core.req`. **Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus, CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer. diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index 4a292d5e..280e6c4c 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -49,13 +49,14 @@ async function signIn(page: Page, email: string, path: string): Promise { // would intercept every click — the test accounts have no YouTube connection, so it auto-opens), // and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel // and player flows this net locks all open "/" expecting the feed; the opt-out is the one - // Settings → Appearance → Startup writes. Both flags are per-account: - // `siftlode.onboarding.dismissed.`, `siftlode.startOnHome.`. + // Settings → Appearance → Startup writes. Both flags are the account's local state, held for the + // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData). await page.goto("/"); await page.evaluate((id: number) => { + localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.lang", "en"); - localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1"); - localStorage.setItem(`siftlode.startOnHome.${id}`, "0"); + localStorage.setItem("siftlode.onboarding.dismissed", "1"); + localStorage.setItem("siftlode.startOnHome", "0"); }, me.id); mkdirSync(dirname(path), { recursive: true }); await page.context().storageState({ path }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 02d6f1c9..b03aac0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -270,8 +270,8 @@ export default function App() { clearParams("link"); }, [t, setPage, refetchMe]); - // A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was - // cancelled at Google's consent screen. The backend only emits this for a live session, so a + // A Google OAuth flow started while already signed in (signing in as someone else, or a Google + // connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a // neutral toast is the right feedback — the pre-auth login banner would never render here. // Same one-shot shape as the `?link=` handler above, same reason for the ref. const oauthAnswered = useRef(false); diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index 76452d45..ad93b2e1 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom // -// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no -// pinned account in sessionStorage) built the app for "no account" first — so with Settings → -// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and -// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the -// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right. +// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab built +// the app for "no account" first — so with Settings → Appearance → Startup turned off, +// NavigationProvider's initializer could not read the opt-out and landed on Home, mirrored ?page=home +// into the address bar, and then re-read its own mirror once the account was known, staying on Home. // // Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe // records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted. @@ -17,7 +16,6 @@ import BootGate from "./BootGate"; import { NavigationProvider } from "./NavigationProvider"; import { useNavigation } from "./navigationContext"; import { api, type Me } from "../lib/api"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; import i18n from "../i18n"; @@ -53,10 +51,17 @@ const account = (over: Partial = {}) => ({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me; let savePrefs: MockInstance; const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") }); +// Whose local state the browser holds (storage.ts claimLocalData). +const owner = () => localStorage.getItem("siftlode.owner"); +// The browser already holds account 5's state, as on any visit after the first. +const heldBy5 = (entries: Record) => { + localStorage.setItem("siftlode.owner", "5"); + for (const [k, v] of Object.entries(entries)) localStorage.setItem(k, v); +}; beforeEach(async () => { localStorage.clear(); - sessionStorage.clear(); // a NEW tab: nothing pinned yet + sessionStorage.clear(); // a NEW tab window.history.replaceState(null, "", "/"); renders.length = 0; mounts = 0; @@ -72,15 +77,14 @@ afterEach(() => { describe("BootGate", () => { it("a new tab with Startup off opens on the page the account left — never on Home first", async () => { - localStorage.setItem("siftlode.startOnHome.5", "0"); - localStorage.setItem("siftlode.page.5", "plex"); + heldBy5({ "siftlode.startOnHome": "0", "siftlode.page": "plex" }); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); expect((await screen.findByTestId("app")).textContent).toBe("plex"); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex")); expect(new URLSearchParams(window.location.search).get("page")).toBe("plex"); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned + expect(owner()).toBe("5"); }); it("renders the app in the account's language from its first render", async () => { @@ -120,12 +124,12 @@ describe("BootGate", () => { expect(renders).toHaveLength(0); }); - it("signed out: mounts the app (App shows the landing) without pinning an account", async () => { + it("signed out: mounts the app (App shows the landing) without claiming the local state", async () => { vi.spyOn(api, "me").mockResolvedValue(null); mount(); await screen.findByTestId("app"); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull(); + expect(owner()).toBeNull(); }); it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => { @@ -135,7 +139,7 @@ describe("BootGate", () => { await screen.findByTestId("app"); expect(me).not.toHaveBeenCalled(); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull(); + expect(owner()).toBeNull(); }); it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => { @@ -148,7 +152,7 @@ describe("BootGate", () => { fireEvent.click(await retryButton()); await screen.findByTestId("app"); expect(renders.length).toBeGreaterThan(0); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account + expect(owner()).toBe("5"); // it mounted for the account }); it("an unanswered first account probe is an error with a retry", async () => { @@ -157,7 +161,7 @@ describe("BootGate", () => { fireEvent.click(await retryButton()); await screen.findByTestId("app"); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); + expect(owner()).toBe("5"); }); it("a background refetch that fails keeps the app mounted, as it was", async () => { @@ -175,17 +179,30 @@ describe("BootGate", () => { expect(mounts).toBe(1); }); - it("a pin naming another account than the server answered as is corrected before the app reads it", async () => { - // Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5. - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9"); + it("an account signing in where another one's state is held never reads it, not for one render", async () => { + // Account 9 used this browser last, and left it on Plex with Startup off. + localStorage.setItem("siftlode.owner", "9"); + localStorage.setItem("siftlode.startOnHome", "0"); + localStorage.setItem("siftlode.page", "plex"); + vi.spyOn(api, "me").mockResolvedValue(account()); + mount(); + + expect((await screen.findByTestId("app")).textContent).toBe("home"); + expect(renders.map((r) => r.page)).toEqual(renders.map(() => "home")); + expect(owner()).toBe("5"); + expect(localStorage.getItem("siftlode.page")).not.toBe("plex"); + }); + + it("an account's state left under the old per-account keys is the one it boots on", async () => { localStorage.setItem("siftlode.startOnHome.5", "0"); localStorage.setItem("siftlode.page.5", "plex"); + localStorage.setItem("siftlode.page.9", "files"); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); expect((await screen.findByTestId("app")).textContent).toBe("plex"); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex")); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); + expect(localStorage.getItem("siftlode.page.9")).toBeNull(); }); it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => { @@ -201,20 +218,20 @@ describe("BootGate", () => { await waitFor(() => expect(reload).toHaveBeenCalled()); }); - it("an identity change reloads without moving the pin first", async () => { - // The tree being torn down still runs its unmount saves before the page goes; with the pin already - // moved they would land under the NEW account's local keys (a player volume still pending, say). + it("an identity change reloads without handing the local state over first", async () => { + // The tree being torn down still runs its unmount saves before the page goes; with the state + // already handed over they would land in the NEW account's (a player volume still pending, say). const me = vi.spyOn(api, "me").mockResolvedValue(account()); const qc = mount(); await screen.findByTestId("app"); const reload = vi.fn(); vi.stubGlobal("location", { ...window.location, reload }); - me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6 + me.mockResolvedValue(account({ id: 6 })); // another tab signed this browser in as 6 await act(() => qc.refetchQueries({ queryKey: qk.me() })); await waitFor(() => expect(reload).toHaveBeenCalled()); - expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); + expect(owner()).toBe("5"); }); // The adoption effect is idempotent for the identity it already adopted, and that is load-bearing diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx index dd8562cb..165edcf1 100644 --- a/frontend/src/components/BootGate.tsx +++ b/frontend/src/components/BootGate.tsx @@ -1,8 +1,9 @@ import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { api, getActiveAccount, setActiveAccount } from "../lib/api"; +import { api } from "../lib/api"; import { log } from "../lib/log"; +import { claimLocalData } from "../lib/storage"; import { saveLanguage } from "../lib/saveLanguage"; import { qk } from "../lib/queryKeys"; import i18n, { isSupported, setLanguage } from "../i18n"; @@ -14,17 +15,16 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; // tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those // providers used to mount before /api/me answered, so a new tab first built the app for "no account" // (defaults: Home instead of the page you left, the default theme, the browser language) and then -// patched each piece once pinned the account. Every patch was a visible jump at best, and at +// patched each piece once knew the account. Every patch was a visible jump at best, and at // worst it read a value the first pass had already written (the landing page mirrored ?page=home into // the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead: // settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount // the provider tree — whose initializers are then simply right, with nothing to re-read. // -// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to -// match the account the server answered as before they run. And the tree is only ever built for one -// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it -// was pinned to was signed out in another tab and the server now answers as the browser's default), -// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous +// The per-account initializers read the browser's local state, which holds one account's: it is handed +// to the account the server answered as (claimLocalData) before they run. And the tree is only ever +// built for one identity: if a mounted tab's identity changes without a reload (its session ended, or +// another tab signed this browser in as someone else), the tab reloads — which also drops the previous // account's query cache, whose keys are not per account. /** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared @@ -76,13 +76,10 @@ export default function BootGate({ children }: { children: ReactNode }) { : (me?.id ?? null); // Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens - // before the browser paints: the frame that first shows the app already has the account pinned. - // • Pin this tab to the account the server answered as. Without the pin, a tab riding the - // session's default account would silently swap identity when ANOTHER tab changes that default - // (adds or switches an account); once pinned it sends its own account header. The server honours - // that header only for an account still signed in to this browser, so a pin naming one that is - // not is corrected here — otherwise the tab would run as the default account on the server while - // reading and writing the gone account's local state. + // before the browser paints: the frame that first shows the app already reads the account's state. + // • Hand the browser's local state to the account the server answered as: a different account + // than the one whose state the browser holds starts from a clean slate, so it never reads (or + // writes over) the previous one's filters, history or positions. // • Adopt the account's language, which follows the account across devices — the local copy only // knows what this browser last showed. An account with none stored yet (the setup wizard's admin, // an account older than the stamp sign-up gives) stores the language it is shown in: the server @@ -98,16 +95,16 @@ export default function BootGate({ children }: { children: ReactNode }) { if (identity === undefined) return; if (readyFor !== undefined) { // Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account — - // see the header. The pin stays as it is: the tree being torn down still runs its unmount saves - // before the page goes, and with the pin moved they would land under the new account's local - // keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for + // see the header. The local state is not handed over here: the tree being torn down still runs + // its unmount saves before the page goes, and they belong to the account it was built for. The + // reloaded page's claim settles it. Comparing rather than merely testing for // "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op // instead of a reload. if (readyFor !== identity) location.reload(); return; } const account = adoptedMe(); - if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); + if (account) claimLocalData(account.id); const lang = account?.preferences?.language; if (typeof lang === "string" && isSupported(lang)) { // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. @@ -121,7 +118,7 @@ export default function BootGate({ children }: { children: ReactNode }) { }), ); } - // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them. + // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the local state is claimed and the language applied, and both are writes to things outside React (storage, i18n). A layout effect so the frame that first shows the app already has them. setReadyFor(identity); }, [identity, readyFor, qc]); diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index c90fa9ac..09abd572 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -5,7 +5,7 @@ import { qk } from "../lib/queryKeys"; import { invalidateSubscription } from "../lib/subscriptionCache"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; -import { accountKey, LS } from "../lib/storage"; +import { LS, ownKey } from "../lib/storage"; import { formatCountOrDash } from "../lib/format"; import { sortRows } from "../lib/columnSort"; import { useSortState } from "../lib/useSortState"; @@ -234,7 +234,7 @@ export default function ChannelDiscovery({ rows={rows} columns={columns} rowKey={(c) => c.id} - persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} + persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined} controlsPosition="both" controlsInBand controlsBandClassName="px-4 max-w-384 mx-auto" diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index fc1e6c85..fa56abc4 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -16,7 +16,7 @@ import ExcludedChannelList from "./ExcludedChannelList"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { Sparkles } from "lucide-react"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { accountKey, LS } from "../lib/storage"; +import { LS, ownKey } from "../lib/storage"; import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns"; import { notify } from "../lib/notifications"; import { Badge } from "./ui/Badge"; @@ -824,7 +824,7 @@ export default function Channels() { rows={syncedChannels} columns={columns} rowKey={(c) => c.id} - persistKey={accountKey(LS.channelsTable) ?? undefined} + persistKey={ownKey(LS.channelsTable) ?? undefined} // Folded, the status / pager row leaves the band; the list's own end keeps it. controlsPosition={fold.folded ? "bottom" : "both"} controlsLeading={statusChips} diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index df316c92..f889f967 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -4,7 +4,7 @@ // and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was // re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E. // -// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so +// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so // useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only // navigationContext (the sole runtime dependency) is mocked, pinned to the channels page. import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -18,16 +18,17 @@ vi.mock("./navigationContext", () => ({ import { ChannelsProvider } from "./ChannelsProvider"; import { useChannels, useChannelsActions } from "./channelsContext"; +import { claimLocalData } from "../lib/storage"; const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper }); -const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id) +const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach) beforeEach(() => { localStorage.clear(); - sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account + claimLocalData(1); // the browser holds account 1's state // Reset BOTH the history state and the query string: setView only owns an entry on the channels page, // and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by // this provider's mount-time URL seed and change the starting tab. @@ -107,10 +108,10 @@ describe("ChannelsProvider — shareable URL", () => { // Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and // persisting the seed overwrote the account's real stored sort + filter map. localStorage.setItem( - "siftlode.channelSort.1", + "siftlode.channelSort", JSON.stringify({ key: "subscribers", dir: "desc" }), ); - localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" })); + localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" })); window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL const { result } = render(); @@ -120,11 +121,11 @@ describe("ChannelsProvider — shareable URL", () => { // ...but the omitted sort + filters keep the account's real stored values, in state AND storage. expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" }); expect(result.current.filters).toEqual({ sync: "needsFull" }); - expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({ + expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({ key: "subscribers", dir: "desc", }); - expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({ + expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({ sync: "needsFull", }); }); diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts index 95181e4a..3112301f 100644 --- a/frontend/src/components/FeedFiltersProvider.dom.test.ts +++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts @@ -5,30 +5,31 @@ // the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost. import { describe, it, expect, beforeEach } from "vitest"; import { loadAccountFilters } from "./feedFiltersInit"; -import { accountKey, LS } from "../lib/storage"; +import { claimLocalData, LS } from "../lib/storage"; -const ID = 7; - -beforeEach(() => localStorage.clear()); +beforeEach(() => { + localStorage.clear(); + claimLocalData(7); +}); describe("loadAccountFilters — legacy tagMode migration on seed", () => { it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => { - localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" })); - const f = loadAccountFilters(ID); + localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" })); + const f = loadAccountFilters(); expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" }); expect(f.q).toBe("hi"); // other fields preserved through the merge }); it("leaves tagModes at defaults when nothing is stored", () => { - expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" }); + expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" }); }); it("preserves an already-migrated per-category tagModes blob", () => { localStorage.setItem( - accountKey(LS.filters, ID)!, + LS.filters, JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }), ); - expect(loadAccountFilters(ID).tagModes).toEqual({ + expect(loadAccountFilters().tagModes).toEqual({ language: "and", topic: "or", other: "and", diff --git a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx index 1123db27..32477f4a 100644 --- a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx +++ b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx @@ -8,9 +8,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NavigationProvider } from "./NavigationProvider"; import { FeedFiltersProvider } from "./FeedFiltersProvider"; import { useFeedFilters } from "./feedFiltersContext"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; import type { Me } from "../lib/api"; +import { claimLocalData } from "../lib/storage"; const scopes: string[] = []; function Probe() { @@ -34,7 +34,7 @@ function mount(me: Partial) { beforeEach(() => { localStorage.clear(); - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5"); + claimLocalData(5); scopes.length = 0; window.history.replaceState(null, "", "/"); }); @@ -53,6 +53,6 @@ describe("FeedFiltersProvider seed", () => { window.history.replaceState(null, "", "/?scope=all"); mount({}); expect(scopes[0]).toBe("all"); - expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all"); + expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all"); }); }); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index a0a4027f..26bdc399 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { getActiveAccount, type FeedFilters } from "../lib/api"; +import { type FeedFilters } from "../lib/api"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState"; import { useUrlMirror } from "../lib/useUrlMirror"; -import { accountKey, writeJSON } from "../lib/storage"; +import { ownKey, writeJSON } from "../lib/storage"; import { logFilterChange } from "../lib/filterLog"; import { useNavigation } from "./navigationContext"; import { @@ -29,12 +29,12 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI function loadInitialFilters(): FeedFilters { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - return loadAccountFilters(getActiveAccount()); + return loadAccountFilters(); } export function FeedFiltersProvider({ children }: { children: ReactNode }) { // Seeded for the account this tab is — BootGate mounts this provider only once the account is - // pinned, so this is the account's own view from the first render. + // claimed, so this is the account's own view from the first render. const [filters, setFiltersState] = useState(loadInitialFilters); // Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured // once at mount — such filters win over the account's stored view and are persisted to the account. @@ -45,8 +45,8 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { const setFilters = useCallback((next: FeedFilters) => { setFiltersState(next); logFilterChange("feed", next, next.q); - // Persist under THIS tab's active account so filters don't bleed between accounts. - const key = accountKey(FILTERS_KEY, getActiveAccount()); + // Persist as the account's own view. + const key = ownKey(FILTERS_KEY); if (key) writeJSON(key, next); }, []); diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx index 1addd226..44888bb7 100644 --- a/frontend/src/components/Files.tsx +++ b/frontend/src/components/Files.tsx @@ -16,7 +16,7 @@ import { formatBytes, formatDateTime, relativeTime } from "../lib/format"; import { focusNavItem } from "../lib/listNav"; import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; -import { LS, accountKey } from "../lib/storage"; +import { LS, ownKey } from "../lib/storage"; import { useCardPager } from "../lib/useCardPager"; import { useDebounced } from "../lib/useDebounced"; import { useLiveQuery } from "../lib/useLiveQuery"; @@ -628,7 +628,7 @@ export default function Files() { rows={rows} columns={columns} rowKey={(f) => String(f.id)} - persistKey={accountKey(LS.filesTable) ?? undefined} + persistKey={ownKey(LS.filesTable) ?? undefined} pageSize={50} keyboardNav fixedLayout diff --git a/frontend/src/components/FilesProvider.dom.test.tsx b/frontend/src/components/FilesProvider.dom.test.tsx index faf610c6..03827042 100644 --- a/frontend/src/components/FilesProvider.dom.test.tsx +++ b/frontend/src/components/FilesProvider.dom.test.tsx @@ -2,7 +2,7 @@ // // FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a // pending focus that the Files page spends once it has looked; what it found is held until the page -// is left. Real storage with the tab's account pinned; only the navigation is mocked, its page +// is left. Real storage, claimed for the account; only the navigation is mocked, its page // switchable. import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; @@ -16,6 +16,7 @@ vi.mock("./navigationContext", () => ({ import { FilesProvider } from "./FilesProvider"; import { ALL_FILES, useFiles, useFilesActions } from "./filesContext"; +import { claimLocalData } from "../lib/storage"; const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -24,7 +25,7 @@ const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }), beforeEach(() => { localStorage.clear(); - sessionStorage.setItem("siftlode.activeAccount", "1"); + claimLocalData(1); window.history.replaceState(null, "", "/"); nav.page = "files"; nav.setPage.mockClear(); diff --git a/frontend/src/components/NavSidebar.account.dom.test.tsx b/frontend/src/components/NavSidebar.account.dom.test.tsx new file mode 100644 index 00000000..44f87760 --- /dev/null +++ b/frontend/src/components/NavSidebar.account.dom.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +// +// The account panel of the rail: one account per browser session, so it lists no other accounts. It +// offers "Sign in as someone else" (straight to Google's account chooser, whose pick replaces this +// account) where the instance has Google sign-in, and "Sign out", which ends the session. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import i18n from "../i18n"; +import { api, type Me } from "../lib/api"; +import NavSidebar from "./NavSidebar"; + +vi.mock("./navigationContext", () => ({ + useNavigation: () => ({ page: "feed" }), + useNavigationActions: () => ({ setPage: vi.fn() }), +})); +vi.mock("./prefsContext", () => ({ usePrefs: () => ({ railLabels: false }) })); + +const me = (over: Partial = {}) => + ({ + id: 5, + email: "me@example.com", + display_name: "Me", + role: "user", + avatar_url: null, + google_email: null, + google_enabled: true, + plex_enabled: false, + preferences: {}, + ...over, + }) as unknown as Me; + +const row = (panel: HTMLElement, text: string) => + [...panel.querySelectorAll("button")].find((b) => b.textContent === text)!; + +let href = ""; +const reload = vi.fn(); + +async function mount(account: Me) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} onChangeLanguage={() => {}} language="en" /> + , + ); + fireEvent.click(screen.getByRole("button", { name: /^Me · / })); + // Positioned by floating-ui, which never settles in jsdom: the panel stays `visibility: hidden`. + return vi.waitFor(() => { + const panel = document.querySelector('[role="dialog"][aria-label="Me"]'); + if (!panel) throw new Error("the account panel did not open"); + return panel; + }); +} + +beforeEach(async () => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + await i18n.changeLanguage("en"); + href = ""; + reload.mockReset(); + vi.stubGlobal("location", { + ...window.location, + reload, + set href(v: string) { + href = v; + }, + get href() { + return href; + }, + }); + vi.spyOn(api, "notificationUnreadCount").mockResolvedValue({ count: 0 } as never); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("the account panel", () => { + it("offers signing in as someone else, which goes straight to Google's account chooser", async () => { + const panel = await mount(me()); + const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim()); + expect(rows).toEqual(["Sign in as someone else", "Sign out"]); + + fireEvent.click(row(panel, "Sign in as someone else")); + expect(href).toBe("/auth/login"); + }); + + it("offers only signing out where the instance has no Google sign-in", async () => { + const panel = await mount(me({ google_enabled: false })); + const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim()); + expect(rows).toEqual(["Sign out"]); + }); + + it("signing out ends the session, then reloads onto the sign-in page", async () => { + const logout = vi.spyOn(api, "logout").mockResolvedValue({ ok: true }); + const panel = await mount(me()); + + fireEvent.click(row(panel, "Sign out")); + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(logout).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 3a7e856e..b4c71227 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,12 +1,10 @@ import { useState, useSyncExternalStore } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./navigationContext"; -import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react"; +import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from "lucide-react"; import { api, type Me } from "../lib/api"; import { displayName, roleKey } from "../lib/api/account"; -import { pinOnNextPage } from "../lib/activeAccount"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; @@ -59,8 +57,8 @@ export default function NavSidebar({ // keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the // rail ever growing. const [acctOpen, setAcctOpen] = useState(false); - // The account panel holds mixed content with its own tab order (profile, account switches, two - // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose + // The account panel holds mixed content with its own tab order (the profile, then its actions), + // so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now @@ -76,41 +74,15 @@ export default function NavSidebar({ const { setReference: setAcctAnchor } = acctPop.refs; async function logout() { - // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then - // drops this tab's override and reloads onto whatever account remains the default. + // Ends this browser's session, then reloads onto the sign-in page. try { await api.logout(); } catch { - /* clear locally and reload regardless */ + /* reload regardless: the page then shows whatever the server says */ } - pinOnNextPage(null); location.reload(); } - // Accounts that have signed in on this browser (loaded only while the popover is open). - const accountsQuery = useQuery({ - queryKey: qk.accounts(), - queryFn: api.accounts, - enabled: acctOpen, - }); - const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); - - function switchTo(id: number) { - // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own - // identity. No server round-trip — req() sends the account header (validated against the - // browser wallet server-side), so the cookie's default account is left untouched. The pin moves with - // the reload, not before it: the page going away still unloads as the account it was built for. - pinOnNextPage(id); - // Drop the previous account's in-module sub-view/overlay before the reload (which would - // otherwise preserve history.state across the swap). Otherwise the new account lands on a - // stale sub-view that belongs to the OLD identity. Start at the module root instead. - const st = { ...(window.history.state || {}) }; - delete st._sub; - delete st._ov; - window.history.replaceState(st, ""); - location.reload(); // cleanest way to reload all per-user state for the new account - } - // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // tab is hidden); coexists with the client-side transient bell at the bottom of the rail. const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, { @@ -270,44 +242,21 @@ export default function NavSidebar({ {t("header.account.admin")} )} - {otherAccounts.length > 0 && ( -
- {otherAccounts.map((a) => ( - switchTo(a.id)} - title={t("header.account.switchTo", { name: a.display_name ?? a.email })} - className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target" - > - - - - {displayName(a)} - - {a.email} - - - ))} -
- )}
- { - // Adding an account switches THIS tab to it: drop the tab's pin so that on - // return from Google it adopts the freshly-added account (the new default) - // and pins that. Other tabs keep their own pinned account. - pinOnNextPage(null); - window.location.href = "/auth/login"; - }} - className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target" - > - - {t("header.account.addAccount")} - + {/* Straight to Google's account chooser: the account picked there takes this one's + place in the browser (one account per session), and a cancel there leaves this + one signed in. A password account signs in from the sign-in page, after Sign out. */} + {me.google_enabled && ( + { + window.location.href = "/auth/login"; + }} + className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target" + > + + {t("header.account.signInAsOther")} + + )} { // An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here. qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true }); localStorage.clear(); - sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory + claimLocalData(1); // the account's page memory window.history.replaceState({ sfPage: "feed" }, "", "/"); }); // Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index c870d70f..b065c2c9 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -23,8 +23,8 @@ vi.mock("react-i18next", async (importOriginal) => { import PlexPlayer from "./PlexPlayer"; import { api, type PlexItemDetail } from "../lib/api"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { pushLayer, removeLayer } from "../lib/layerStack"; +import { claimLocalData } from "../lib/storage"; type Media = HTMLMediaElement & { _t?: number; _paused?: boolean }; @@ -87,7 +87,7 @@ beforeAll(() => { let play: ReturnType; let pause: ReturnType; beforeEach(() => { - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7"); + claimLocalData(7); play = vi.fn(function (this: Media) { this._paused = false; this.dispatchEvent(new Event("play")); @@ -1034,7 +1034,7 @@ describe("PlexPlayer — clip mode", () => { }); const prefs = (p: object) => - localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p })); + localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p })); const infoOverlay = () => document.querySelector("[class*='bg-black/70']"); it("Escape closes what is over the picture first, then the crop and the panel", async () => { diff --git a/frontend/src/components/PlexPlayer.hls.dom.test.tsx b/frontend/src/components/PlexPlayer.hls.dom.test.tsx index e740ed1d..fb99335f 100644 --- a/frontend/src/components/PlexPlayer.hls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.hls.dom.test.tsx @@ -50,7 +50,7 @@ vi.mock("hls.js", async (importOriginal) => { import Hls from "hls.js"; import PlexPlayer from "./PlexPlayer"; import { api, type PlexItemDetail } from "../lib/api"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; +import { claimLocalData } from "../lib/storage"; type Media = HTMLMediaElement & { _t?: number }; @@ -91,7 +91,7 @@ beforeAll(() => { }); beforeEach(() => { - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7"); + claimLocalData(7); hlsInstances.length = 0; }); diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index 87535af8..8ab3085d 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -21,7 +21,7 @@ import PlexPlayer from "./PlexPlayer"; import { api, HttpError, type PlexItemDetail } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; +import { claimLocalData } from "../lib/storage"; type Media = HTMLMediaElement & { _t?: number }; @@ -75,8 +75,8 @@ beforeAll(() => { beforeEach(() => { // A signed-in account whose saved subtitle language is English (prefs are per account). - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7"); - localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" })); + claimLocalData(7); + localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en" })); }); afterEach(() => { @@ -310,7 +310,7 @@ const startsAsked = (id: string) => describe("leaving an item that was played through", () => { const prefs = (p: object) => - localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p })); + localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p })); // The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires // 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not diff --git a/frontend/src/components/PlexProvider.dom.test.tsx b/frontend/src/components/PlexProvider.dom.test.tsx index 2ce759ae..f69d16b0 100644 --- a/frontend/src/components/PlexProvider.dom.test.tsx +++ b/frontend/src/components/PlexProvider.dom.test.tsx @@ -9,7 +9,7 @@ import { render, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NavigationProvider } from "./NavigationProvider"; import { PlexProvider } from "./PlexProvider"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; +import { claimLocalData } from "../lib/storage"; function mount() { render( @@ -25,7 +25,7 @@ function mount() { beforeEach(() => { localStorage.clear(); - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5"); + claimLocalData(5); }); afterEach(() => { cleanup(); @@ -37,9 +37,9 @@ describe("PlexProvider shared link", () => { window.history.replaceState(null, "", "/?page=plex&pscope=movie"); mount(); - expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie"); - expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull(); - expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull(); - expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull(); + expect(localStorage.getItem("siftlode.plexLibrary")).toBe("movie"); + expect(localStorage.getItem("siftlode.plexFilters")).toBeNull(); + expect(localStorage.getItem("siftlode.plexSort")).toBeNull(); + expect(localStorage.getItem("siftlode.plexShow")).toBeNull(); }); }); diff --git a/frontend/src/components/PrefsProvider.dom.test.tsx b/frontend/src/components/PrefsProvider.dom.test.tsx index 7dd9a8d5..8cf49c95 100644 --- a/frontend/src/components/PrefsProvider.dom.test.tsx +++ b/frontend/src/components/PrefsProvider.dom.test.tsx @@ -10,8 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PrefsProvider } from "./PrefsProvider"; import { usePrefs } from "./prefsContext"; import { api } from "../lib/api"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; -import { accountKey, LS } from "../lib/storage"; +import { claimLocalData, LS } from "../lib/storage"; import { qk } from "../lib/queryKeys"; const seen: { perf: boolean; hints: boolean }[] = []; @@ -23,7 +22,7 @@ function Probe() { beforeEach(() => { localStorage.clear(); - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5"); + claimLocalData(5); seen.length = 0; vi.useFakeTimers(); }); @@ -35,8 +34,8 @@ afterEach(() => { describe("PrefsProvider seed", () => { it("renders the account's server prefs from the first render, and does not save them back", () => { - localStorage.setItem(accountKey(LS.perfMode, 5)!, "0"); // this browser last had it off - localStorage.setItem(accountKey(LS.hints, 5)!, "1"); + localStorage.setItem(LS.perfMode, "0"); // this browser last had it off + localStorage.setItem(LS.hints, "1"); const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never); const qc = new QueryClient(); qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } }); diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx index e242db89..5bfe0a2b 100644 --- a/frontend/src/components/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -45,8 +45,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); import { SavedViewsProvider } from "./SavedViewsProvider"; import { useSavedViews } from "./savedViewsContext"; import { api, type SavedView } from "../lib/api"; -import { LS, accountKey } from "../lib/storage"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; +import { claimLocalData, LS } from "../lib/storage"; import { qk } from "../lib/queryKeys"; const ID = 7; @@ -95,7 +94,7 @@ async function mountResolved(views: SavedView[]) { beforeEach(() => { localStorage.clear(); - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID)); + claimLocalData(ID); // Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior // case would suppress seeding in the others. window.history.replaceState({}, "", "/"); @@ -118,7 +117,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { }); it("feed: does NOT seed when the account already has stored filters", async () => { - localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" })); + localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" })); await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]); expect(h.setFeedFilters).not.toHaveBeenCalled(); }); @@ -177,7 +176,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { it("plex: does NOT seed when plex filters are already stored", async () => { h.page = "plex"; - localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] })); + localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] })); await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); expect(h.setPlexFilters).not.toHaveBeenCalled(); }); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 4ed4087d..ccd456d1 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -5,7 +5,6 @@ import { qk } from "../lib/queryKeys"; import { api, coercePlexFilters, - getActiveAccount, plexFiltersSignature, type FeedFilters, type PlexFilters, @@ -14,7 +13,7 @@ import { import { filtersToParams, hasFilterParams } from "../lib/urlState"; import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; -import { accountKey, LS } from "../lib/storage"; +import { LS, ownKey } from "../lib/storage"; import { useConfirm } from "./confirmContext"; import { useNavigation } from "./navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; @@ -55,7 +54,7 @@ const plexKeyOf = (f: Record) => plexFiltersSignature(coercePle // view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable → // report "stored" so a default never seeds blind over an indeterminate state. function storedFiltersPresent(lsKey: string): boolean { - const key = accountKey(lsKey, getActiveAccount()); + const key = ownKey(lsKey); if (!key) return true; try { return localStorage.getItem(key) != null; diff --git a/frontend/src/components/channelChipNames.dom.test.tsx b/frontend/src/components/channelChipNames.dom.test.tsx index 9dccbdaf..edec7852 100644 --- a/frontend/src/components/channelChipNames.dom.test.tsx +++ b/frontend/src/components/channelChipNames.dom.test.tsx @@ -8,8 +8,7 @@ import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-libr import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement, type ReactNode } from "react"; import { api, type FeedFilters, type ManagedChannel } from "../lib/api"; -import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; -import { accountKey, LS } from "../lib/storage"; +import { claimLocalData, LS } from "../lib/storage"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import ChannelPicker from "./ChannelPicker"; @@ -28,7 +27,7 @@ const filters: FeedFilters = { ...clearedFilters({ q: "" } as FeedFilters), channelIds: [KNOWN, NEW], }; -const namesKey = () => accountKey(LS.channelNames, 5)!; +const namesKey = () => LS.channelNames; let resolveChannels: (c: ManagedChannel[]) => void; let qc: QueryClient; @@ -37,7 +36,7 @@ const wrapper = ({ children }: { children: ReactNode }) => beforeEach(() => { localStorage.clear(); - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5"); + claimLocalData(5); localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" })); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); vi.spyOn(api, "tags").mockResolvedValue([] as never); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 3e2a471c..b9969c2f 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -61,10 +61,7 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). const remote = job.asset?.thumbnail_url ?? null; - // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see - // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the - // poster resolves to the session-default account and 404s for a per-tab account's download — which, - // now that the poster is the primary image, reads as a blank Library thumbnail. + // `job.poster_url` only SIGNALS a poster exists; the src is api.posterImageUrl. const poster = job.poster_url ? api.posterImageUrl(job.id) : null; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. An image is its own picture (its sandboxed preview); the rest show diff --git a/frontend/src/components/feedFiltersInit.ts b/frontend/src/components/feedFiltersInit.ts index d9eb0ccd..7aa97198 100644 --- a/frontend/src/components/feedFiltersInit.ts +++ b/frontend/src/components/feedFiltersInit.ts @@ -1,5 +1,5 @@ import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api"; -import { accountKey, LS, readJSON } from "../lib/storage"; +import { LS, ownKey, readJSON } from "../lib/storage"; // The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider // (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh). @@ -23,16 +23,16 @@ export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"] export const FILTERS_KEY = LS.filters; -// This account's own persisted filters (per-account keys — never another account's). No account +// This account's own persisted filters (see ownKey). No account // (signed out) → defaults. -export function loadAccountFilters(id: number | null): FeedFilters { +export function loadAccountFilters(): FeedFilters { // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred // default view seeds a fresh account that has never stored filters, but that is applied ONCE by // SavedViewsProvider on its views query's first resolve (which then persists here like any edit) — // not read from a mirror on this synchronous path, which used to race that resolve. So here we only // ever read the account's own stored filters, else fall back to DEFAULT_FILTERS. - const fKey = accountKey(FILTERS_KEY, id); + const fKey = ownKey(FILTERS_KEY); let hasStored = false; try { hasStored = !!fKey && localStorage.getItem(fKey) != null; diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 6749d15c..e0c2e500 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -32,7 +32,7 @@ const PLAIN: Record = { "the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)", ], "LanguageSwitcher.tsx": [1, "an option row"], - "NavSidebar.tsx": [4, "the account trigger and its menu rows"], + "NavSidebar.tsx": [3, "the account trigger and its menu rows"], "PaletteLauncher.tsx": [2, "launcher controls"], "PlaylistsRail.tsx": [1, "the load-error retry"], "PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 442f9099..8f9efdcf 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -30,8 +30,7 @@ "settings": "Settings", "about": "About", "signOut": "Sign out", - "addAccount": "Add another account", - "switchTo": "Switch to {{name}}", + "signInAsOther": "Sign in as someone else", "googleAddress": "Google account: {{email}}", "activity": "Activity" }, diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 972073b8..12fb7c45 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -30,8 +30,7 @@ "settings": "Beállítások", "about": "Névjegy", "signOut": "Kijelentkezés", - "addAccount": "Másik fiók hozzáadása", - "switchTo": "Váltás erre: {{name}}", + "signInAsOther": "Belépés más fiókkal", "googleAddress": "Google-fiók: {{email}}", "activity": "Napló" }, diff --git a/frontend/src/lib/accountPinnedStores.dom.test.ts b/frontend/src/lib/accountPinnedStores.dom.test.ts deleted file mode 100644 index 25160abc..00000000 --- a/frontend/src/lib/accountPinnedStores.dom.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment jsdom -// -// The notification and hints stores read their per-account localStorage when they are imported. In a new -// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they -// read as nobody's; they must read again on the pin. The notification store used to keep its empty, -// nobody's history, and the tab's first notice replaced the account's stored history with it. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const entry = (id: number, message: string) => ({ - id, - level: "info", - message, - ts: 1, - read: true, - dismissed: true, - transient: false, - repeat: 1, - requiresInteraction: false, -}); -const storedMessages = (account: number) => - ( - JSON.parse(localStorage.getItem(`siftlode.notifications.${account}`) ?? "[]") as { - message: string; - }[] - ).map((n) => n.message); - -// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s -// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives -// vi.resetModules, so each case still gets fresh module instances, only faster. -beforeAll(async () => { - await import("./notifications"); - await import("./hints"); - await import("./api/core"); -}, 60_000); - -// Warm or not, each case still loads and evaluates the modules it imports afresh after its module -// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's -// workers share a busy machine. -const CASE_BUDGET = 30_000; - -beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - vi.resetModules(); // both stores load at import: each test needs a fresh one -}); - -describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => { - it("keep the account's notification history and settings once the tab is pinned", async () => { - localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")])); - localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 })); - const notifications = await import("./notifications"); - const { setActiveAccount } = await import("./api/core"); - - notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe - setActiveAccount(5); - notifications.notify({ message: "after the pin" }); - - expect(storedMessages(5)).toEqual(["older", "before the pin", "after the pin"]); - expect(notifications.getNotifSettings().durationMs).toBe(0); - }); - - it("read the account's hints choice once the tab is pinned", async () => { - localStorage.setItem("siftlode.hints.5", "0"); - const hints = await import("./hints"); - const { setActiveAccount } = await import("./api/core"); - - expect(hints.hintsEnabled()).toBe(true); // nobody's: the default - setActiveAccount(5); - expect(hints.hintsEnabled()).toBe(false); - }); -}); - -describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => { - it("leaves the previous account's notices with that account", async () => { - // BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as. - sessionStorage.setItem("siftlode.activeAccount", "4"); - localStorage.setItem("siftlode.notifications.4", JSON.stringify([entry(7, "four's")])); - localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(8, "five's")])); - const notifications = await import("./notifications"); - const { setActiveAccount } = await import("./api/core"); - - notifications.notify({ message: "as four" }); - setActiveAccount(5); - notifications.notify({ message: "as five" }); - - expect(storedMessages(5)).toEqual(["five's", "as five"]); - expect(storedMessages(4)).toEqual(["four's", "as four"]); - }); -}); diff --git a/frontend/src/lib/activeAccount.dom.test.ts b/frontend/src/lib/activeAccount.dom.test.ts deleted file mode 100644 index 0cc12ff0..00000000 --- a/frontend/src/lib/activeAccount.dom.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @vitest-environment jsdom -// -// A switch or sign-out hands the tab's next page its account instead of moving the pin: the page being left -// still unloads as its own account. The players' resume beacons read the pin as they fire on the way out, -// and with the pin already moved they saved one account's playback position into the other. -import { beforeEach, describe, expect, it, vi } from "vitest"; - -beforeEach(() => { - sessionStorage.clear(); - sessionStorage.setItem("siftlode.activeAccount", "3"); - vi.resetModules(); // the hand-over is taken at import: each import below is a page load -}); - -describe("pinOnNextPage", () => { - it("leaves the pin alone until the next page, which takes the account over once", async () => { - const leaving = await import("./activeAccount"); - leaving.pinOnNextPage(7); - expect(leaving.activeAccountId()).toBe(3); // the unload still acts as 3 - - vi.resetModules(); - const next = await import("./activeAccount"); - expect(next.activeAccountId()).toBe(7); - - next.pinActiveAccount(9); // BootGate corrects it - vi.resetModules(); // and an F5 keeps the correction - expect((await import("./activeAccount")).activeAccountId()).toBe(9); - }); - - it("hands over no account at all, so the next page adopts the session's default", async () => { - const leaving = await import("./activeAccount"); - leaving.pinOnNextPage(null); - expect(leaving.activeAccountId()).toBe(3); - - vi.resetModules(); - expect((await import("./activeAccount")).activeAccountId()).toBeNull(); - }); -}); diff --git a/frontend/src/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts deleted file mode 100644 index c57493ff..00000000 --- a/frontend/src/lib/activeAccount.ts +++ /dev/null @@ -1,78 +0,0 @@ -// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module -// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage -// on it, without a circular import — the account id is a lower-level primitive than the storage -// helpers that consume it. -// -// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads -// the page (handing the new pin over through `pinOnNextPage`), so nothing mounted ever sees the id change -// underneath it. The one exception is state read at -// module import (the notification and hints stores), which in a new tab runs before the pin: it reads -// again through `onAccountPinned`. -// -// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free. -export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount"; - -// Re-readers for module-level per-account state, run each time the tab is pinned. -const pinListeners = new Set<() => void>(); - -export function onAccountPinned(reload: () => void): void { - pinListeners.add(reload); -} - -export function pinActiveAccount(id: number): void { - try { - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); - } catch { - /* ignore */ - } - pinListeners.forEach((reload) => reload()); -} - -// The account the tab's NEXT page is pinned to, handed over by a switch or sign-out that is about to leave. -// The pin itself stays until the page is gone: the unload still runs as the account the page was built for -// (the players' resume beacons read the pin as they fire), and with the pin already moved it would save one -// account's position into the other. -const NEXT_ACCOUNT_KEY = "siftlode.nextAccount"; - -/** Pin the tab's next page to `id` — or to none, so it adopts the session's default account. */ -export function pinOnNextPage(id: number | null): void { - try { - sessionStorage.setItem(NEXT_ACCOUNT_KEY, id == null ? "" : String(id)); - } catch { - /* ignore */ - } -} - -// Taken over at import, before anything reads the pin. -try { - const next = sessionStorage.getItem(NEXT_ACCOUNT_KEY); - if (next != null) { - sessionStorage.removeItem(NEXT_ACCOUNT_KEY); - if (next) sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, next); - else sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); - } -} catch { - /* ignore */ -} - -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; - } -} - -// Append `?account=` to a top-level navigation URL (SEC-11). XHRs carry the active account in -// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth -// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the -// identity/token would then land on the wrong account. This threads the active account through the URL -// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet. -export function withActiveAccount(path: string): string { - const id = activeAccountId(); - if (id == null) return path; - return `${path}${path.includes("?") ? "&" : "?"}account=${id}`; -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 25fb0963..4cd31714 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,10 +6,10 @@ // 3. spreads each domain's method slice into the single `api` object consumers call. // A new endpoint therefore has nowhere to land except a domain module — the interleave that // motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.) -import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core"; +import { HttpError, setUnauthorizedHandler } from "./api/core"; // Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. -export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler }; +export { HttpError, setUnauthorizedHandler }; import { accountApi } from "./api/account"; export type * from "./api/account"; diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 449cdca9..442aff53 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -49,14 +49,6 @@ export function displayName(a: { display_name: string | null; email: string }): return a.display_name ?? a.email.split("@")[0]!; } -export interface Account { - id: number; - email: string; - display_name: string | null; - avatar_url: string | null; - active: boolean; -} - export interface VersionInfo { app_version: string; git_sha: string; @@ -73,14 +65,9 @@ export const accountApi = { const r = await req<{ authenticated: boolean } & Partial>("/api/me"); return r.authenticated ? (r as Me) : null; }, - accounts: (): Promise => req("/api/me/accounts"), deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }), - switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => - req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), - // Signs the current tab's active account out of the browser wallet (per-tab aware via the - // X-Siftlode-Account header that req() attaches). - logout: (): Promise<{ ok: boolean; switched: boolean }> => - req("/auth/logout", { method: "POST" }), + // Ends this browser's session. + logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }), version: (): Promise => req("/api/version"), // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. savePrefs: (p: Record) => diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index 98ca4373..68b66abf 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -16,11 +16,6 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({ vi.mock("../errorDialog", () => ({ reportError })); vi.mock("../notifications", () => ({ notify, remove, getActiveToasts })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); -vi.mock("../activeAccount", () => ({ - activeAccountId: () => null, - ACTIVE_ACCOUNT_KEY: "k", - pinActiveAccount: () => {}, -})); import { setDebugSession } from "../log"; import { downloadsApi } from "./downloads"; @@ -155,9 +150,9 @@ describe("uploadReq", () => { }); it("lets its own identity headers win over a caller's", () => { - // Who a request is from is this layer's to state, not its caller's. Merged the other way round, - // a caller's header of the same name would silently re-attribute the upload — to another - // account, or (here) into someone else's debug session recording. + // What a request is part of is this layer's to state, not its caller's. Merged the other way + // round, a caller's header of the same name would silently re-attribute the upload into someone + // else's debug session recording. setDebugSession("0123456789abcdef"); try { void uploadReq("/u", body(), { diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 48832e77..5b993257 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -1,12 +1,11 @@ // The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed -// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing, +// error, structured-error localization, the connectivity toast, the identity headers, // and the keepalive beacon. Domain modules import from here; nothing here imports a domain module, // so the runtime graph is a clean barrel → domain → core with no cycles. import { getActiveToasts, notify, remove } from "../notifications"; import i18n from "../../i18n"; import { formatBytes, formatDecimal } from "../format"; import { reportError } from "../errorDialog"; -import { activeAccountId, pinActiveAccount } from "../activeAccount"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { currentDebugSession, newRequestId } from "../log"; @@ -268,28 +267,16 @@ export const setupHeaders = (token: string) => ({ "X-Setup-Token": token, }); -// --- Per-tab active account -------------------------------------------------------------- -// 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"; // The debug session this tab is recording: the server tags its own lines of the request with // it, and logs them at the session's level. Ignored by the server unless it is the caller's own. const DEBUG_SESSION_HEADER = "X-Debug-Session"; -export const getActiveAccount = activeAccountId; - -/** Who this request is from — the tab's account and the debug session it records into — as the - * headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path - * cannot quietly go out without them. */ +/** What this request carries besides the session cookie (which alone says who is asking) — the + * debug session it records into — as the headers every request path sends (`req`, `beacon`, + * `uploadReq`): one definition, so a path cannot quietly go out without them. */ export function identityHeaders(): Record { - const active = getActiveAccount(); const session = currentDebugSession(); - return { - ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), - ...(session ? { [DEBUG_SESSION_HEADER]: session } : {}), - }; + return session ? { [DEBUG_SESSION_HEADER]: session } : {}; } // Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must @@ -315,8 +302,6 @@ export function beacon(url: string, body: Record, onSettled?: ( } } -export const setActiveAccount = pinActiveAccount; - export async function req( url: string, opts: RequestInit = {}, @@ -398,7 +383,7 @@ export async function req( // Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload // uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional -// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header, +// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers, // credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an // `HttpError` throw — so callers handle failures exactly as they do a `req` call. // diff --git a/frontend/src/lib/api/diagnostics.dom.test.ts b/frontend/src/lib/api/diagnostics.dom.test.ts index af46e429..cc145d13 100644 --- a/frontend/src/lib/api/diagnostics.dom.test.ts +++ b/frontend/src/lib/api/diagnostics.dom.test.ts @@ -13,11 +13,6 @@ vi.mock("../notifications", () => ({ getActiveToasts: vi.fn((): { id: number }[] => []), })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); -vi.mock("../activeAccount", () => ({ - activeAccountId: () => null, - ACTIVE_ACCOUNT_KEY: "k", - pinActiveAccount: () => {}, -})); import { debugSessionsApi } from "./diagnostics"; diff --git a/frontend/src/lib/api/diagnostics.ts b/frontend/src/lib/api/diagnostics.ts index a074b0da..deee93e7 100644 --- a/frontend/src/lib/api/diagnostics.ts +++ b/frontend/src/lib/api/diagnostics.ts @@ -2,7 +2,7 @@ // beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`. import type { LogEvent } from "../log"; import type { ServerLogRow } from "./admin"; -import { beacon, getActiveAccount, identityHeaders, req } from "./core"; +import { beacon, identityHeaders, req } from "./core"; /** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */ export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE"; @@ -123,12 +123,7 @@ export const debugSessionsApi = { req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }), removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> => req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }), - /** Where an `` loads a screenshot from. An `` cannot send the account header, so the - * tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a - * per-tab account's screenshot resolves to the session default and 404s. */ - debugScreenshotUrl: (id: string, shotId: number): string => { - const a = getActiveAccount(); - const url = `${SESSIONS}/${id}/screenshots/${shotId}`; - return a != null ? `${url}?account=${a}` : url; - }, + /** Where an `` loads a screenshot from. */ + debugScreenshotUrl: (id: string, shotId: number): string => + `${SESSIONS}/${id}/screenshots/${shotId}`, }; diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index f0e3b485..c959c63d 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -1,7 +1,7 @@ // Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the // phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only -// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core. -import { beacon, req, getActiveAccount } from "./core"; +// reference each other, and its methods only touch the shared `req`/`beacon` from core. +import { beacon, req } from "./core"; export interface DownloadSpec { mode: "av" | "v" | "a"; // audio+video | video-only | audio-only @@ -314,22 +314,10 @@ export const downloadsApi = { }, onSettled, ), - // Filmstrip sprite (an /background src → direct nav, so it needs the ?account= wallet gate). - storyboardImageUrl: (id: number): string => { - const a = getActiveAccount(); - return a != null - ? `/api/downloads/${id}/storyboard.jpg?account=${a}` - : `/api/downloads/${id}/storyboard.jpg`; - }, - // The locally-generated poster (an src → direct nav, so same ?account= wallet gate). Without - // it the poster resolves to the session-default account and 404s for a per-tab account's download — - // which reads as a blank Library thumbnail once the poster is the card's primary image. - posterImageUrl: (id: number): string => { - const a = getActiveAccount(); - return a != null - ? `/api/downloads/${id}/poster.jpg?account=${a}` - : `/api/downloads/${id}/poster.jpg`; - }, + // Filmstrip sprite (an /background src). + storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`, + // The locally-generated poster (an src). + posterImageUrl: (id: number): string => `/api/downloads/${id}/poster.jpg`, // admin adminDownloads: (): Promise => req("/api/admin/downloads"), diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index d4ab037a..c1f0ba78 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -1,6 +1,6 @@ // The Files library: a user's done files, the folders they sit in, and the file itself // (backend `routes/files.py`). A library file is a done download job, so it is typed as one. -import { getActiveAccount, req, uploadReq } from "./core"; +import { req, uploadReq } from "./core"; import type { DownloadJob } from "./downloads"; // "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user; @@ -144,19 +144,10 @@ export const filesApi = { headers, }); }, - // A plain navigation can't send the X-Siftlode-Account header, so pass the active account via - // ?account= (the server honours it only for an account in this browser's wallet) — otherwise the - // file resolves to the session-default account and 404s for a per-tab account's file. - fileUrl: (id: number): string => { - const a = getActiveAccount(); - return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`; - }, - // The viewer's copy of an image or a PDF (`/preview`, sandboxed): an /