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 /