fix(auth): a tab acts only as the account it was built for, and the review's other findings

A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
2026-10-21 01:05:12 +02:00
parent 28b77fed1e
commit 94418a087e
39 changed files with 576 additions and 78 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ function readStoredFilters(): FilterMap {
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search);
@@ -7,7 +7,8 @@ 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 { api, HttpError, type Me } from "../lib/api";
import * as actionErrors from "../lib/actionErrors";
import NavSidebar from "./NavSidebar";
vi.mock("./navigationContext", () => ({
@@ -108,4 +109,17 @@ describe("the account panel", () => {
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
expect(logout).toHaveBeenCalledOnce();
});
// Reloading after a sign-out that failed only brought the session back, with no word of why.
it("a sign-out that failed says so and keeps the page", async () => {
vi.spyOn(api, "logout").mockRejectedValue(new HttpError(500, "boom"));
const report = vi.spyOn(actionErrors, "notifyActionError").mockImplementation(() => {});
const panel = await mount(me());
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
expect(reload).not.toHaveBeenCalled();
});
});
+11 -7
View File
@@ -34,6 +34,7 @@ import {
} from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
import { pageHref } from "../lib/nav";
import { notifyActionError } from "../lib/actionErrors";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -74,13 +75,16 @@ export default function NavSidebar({
const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() {
// Ends this browser's session, then reloads onto the sign-in page.
try {
await api.logout();
} catch {
/* reload regardless: the page then shows whatever the server says */
}
location.reload();
// Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
// so and stays: the reload would only bring back the session that is still there.
const signedOut = await api.logout().then(
() => true,
(err: unknown) => {
notifyActionError(err, t("errors.writeFailed.signOut"));
return false;
},
);
if (signedOut) location.reload();
}
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
@@ -31,7 +31,7 @@ import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { api } from "../lib/api";
import { PAGE_KEY } from "./navigationInit";
@@ -350,6 +350,26 @@ describe("NavigationProvider · a page the account cannot open", () => {
await settle();
expect(result.current.page).toBe("feed");
});
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
// feed was already on screen), and a later grant switched the page unasked.
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
const { result } = render();
expect(result.current.page).toBe("users");
act(() => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(window.location.search).toBe("");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => {
qc.setQueryData(qk.me(), { ...member, role: "admin" });
});
await settle();
expect(result.current.page).toBe("feed");
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
+41 -14
View File
@@ -38,6 +38,24 @@ import {
//
// The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
};
}
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
@@ -49,6 +67,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// and the address bar (stamped from it below) agree.
const me = useMe();
const page = reachablePage(chosenPage, me);
// A page this account can no longer open (a role taken away, a module switched off mid-session)
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
// grant would switch the page by itself. Adjusted during render; the history entry and the
// remembered page follow in the effect below.
const [givenUp, setGivenUp] = useState(0);
if (page !== chosenPage) {
setPageState(page);
setGivenUp((n) => n + 1);
}
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
@@ -97,20 +125,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}),
urlWithParams(pageRootParams(next)),
);
notifyNav();
then?.();
@@ -195,6 +210,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]);
// The page given up above is the feed, and the current entry says so (replaced, not pushed: no
// step was taken). Its own keys go with it, as on any page switch.
useEffect(() => {
if (!givenUp) return;
setAccountRaw(PAGE_KEY, "feed");
window.history.replaceState(
{ ...window.history.state, sfPage: "feed" },
"",
urlWithParams(pageRootParams("feed")),
);
}, [givenUp]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
const [mountPage] = useState(page);
+2 -2
View File
@@ -28,7 +28,7 @@ import {
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
@@ -84,7 +84,7 @@ function readPlexFilters(): PlexFilters {
return EMPTY_PLEX_FILTERS;
}
}
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
// `present` records which dimensions the link actually carried: only those are persisted (below).
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
@@ -146,8 +146,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
// state to the account (claimLocalData) before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
// fire on the views query's first resolve for a source and never again, which `seededRef`