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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user