Loading panel/src/lib/api.test.ts +16 −1 Changes for panel/src/lib/api.test.ts: 16 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -12,7 +12,7 @@ vi.mock("./config", () => ({ })); // Imported after the mock so api.ts picks up the mocked loadConfig. const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } = const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, ACCESS_REFUSED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } = await import("./api"); function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) { Loading Loading @@ -1099,6 +1099,21 @@ describe("session and connection signals", () => { expect(seen).toHaveLength(0); }); it("announces a role refusal, and no other 403", async () => { const seen = listen(ACCESS_REFUSED_EVENT); const refuse = async (status: number, code: string) => { vi.stubGlobal("fetch", fakeFetch({ error: { code, message: "x" } }, { ok: false, status })); await expect(api.myServers()).rejects.toMatchObject({ status, code }); }; for (const code of ["reauth_required", "setup_required", "quota_exceeded", "local_auth_disabled"]) await refuse(403, code); await refuse(409, "forbidden"); expect(seen).toHaveLength(0); await refuse(403, "forbidden"); await refuse(403, "not_admin"); expect(seen).toHaveLength(2); }); it("reports a fetch that got no response, and the next one that did", async () => { const seen = listen(CONNECTION_EVENT); vi.stubGlobal( Loading panel/src/lib/api.ts +13 −0 Changes for panel/src/lib/api.ts: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -79,6 +79,18 @@ function announceSessionExpired(err: ApiError, path: string): void { window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)); } // A 403 forbidden or not_admin is the role gate refusing the call. The account // may have been demoted since /me was read, which leaves admin pages and buttons // up that every call now refuses. TierProvider hears this and re-reads /me, which // takes them down; a refusal the current role explains changes nothing there. export const ACCESS_REFUSED_EVENT = "felis:access-refused"; const ROLE_REFUSALS = new Set(["forbidden", "not_admin"]); function announceAccessRefused(err: ApiError): void { if (err.status !== 403 || !ROLE_REFUSALS.has(err.code) || typeof window === "undefined") return; window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT)); } // CONNECTION_EVENT reports when requests stop reaching the API (detail.ok = // false) and when one gets through again (true). A fetch that rejects never // saw a response: the network is down, or Cloudflare Access redirected the Loading Loading @@ -178,6 +190,7 @@ function failed(path: string, status: number, statusText: string, text: string): }; announceSetupRequired(err); announceSessionExpired(err, path); announceAccessRefused(err); return err; } Loading panel/src/lib/tier.test.tsx +45 −2 Changes for panel/src/lib/tier.test.tsx: 45 added lines, 2 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, render, screen } from "@testing-library/react"; import { TierProvider, useTier } from "./tier"; import { ACCESS_RECHECK_MS, TierProvider, useTier } from "./tier"; import { ACCESS_REFUSED_EVENT } from "./api"; import type { Identity } from "./types"; const calls = vi.hoisted(() => ({ me: vi.fn() })); Loading Loading @@ -31,6 +32,9 @@ async function boot() { beforeEach(() => { calls.me.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); describe("TierProvider after a /me that failed with anything but a 401", () => { it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => { Loading Loading @@ -67,3 +71,42 @@ describe("TierProvider after a /me that failed with anything but a 401", () => { expect(tier.unauthenticated).toBe(false); }); }); // An admin demoted while the page is open keeps the admin pages up until /me is // read again; the first refused call does that, at most once per interval. describe("TierProvider after a role refusal", () => { const demoted: Identity = { ...admin, role: "user", is_admin: false }; const refuse = () => act(async () => { window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT)); }); it("re-reads /me and takes the admin view down once the account was demoted", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); calls.me.mockResolvedValue(admin); await boot(); expect(screen.getByText("admin")).toBeTruthy(); calls.me.mockResolvedValue(demoted); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS)); await refuse(); expect(await screen.findByText("not admin")).toBeTruthy(); expect(calls.me).toHaveBeenCalledTimes(2); }); it("re-reads at most once per interval", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); calls.me.mockResolvedValue(admin); await boot(); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2)); await refuse(); expect(calls.me).toHaveBeenCalledTimes(1); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2)); await refuse(); await refuse(); expect(calls.me).toHaveBeenCalledTimes(2); expect(screen.getByText("admin")).toBeTruthy(); }); }); panel/src/lib/tier.tsx +15 −4 Changes for panel/src/lib/tier.tsx: 15 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import type { Identity } from "./types"; import { api, SESSION_EXPIRED_EVENT } from "./api"; import { ACCESS_REFUSED_EVENT, api, SESSION_EXPIRED_EVENT } from "./api"; import { deriveAuth, isUnauthorized, type AuthState } from "./auth"; // TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing Loading @@ -32,10 +32,12 @@ import { deriveAuth, isUnauthorized, type AuthState } from "./auth"; // re-reads /me after a login / logout so the gate re-evaluates without a reload. // // 4. Session-aware while open: a 401 from any protected call (api.ts announces // SESSION_EXPIRED_EVENT), the tab coming back into view, or the window // SESSION_EXPIRED_EVENT), a role refusal (ACCESS_REFUSED_EVENT, at most once // per ACCESS_RECHECK_MS), the tab coming back into view, or the window // regaining focus re-reads /me through `revalidate()`. It leaves `loading` // alone, so the app stays mounted during the check; only a 401 changes what // is shown, and `sessionEnded` tells the login page why the person landed there. // alone, so the app stays mounted during the check; a 401 signs the person // out, a changed role redraws the gates (a demoted admin loses the admin // pages), and `sessionEnded` tells the login page why the person landed there. // // Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1. Loading @@ -56,6 +58,9 @@ export interface TierState extends AuthState { // A tab returning to view re-checks /me at most this often; a 401 from a real // call re-checks at once. const REVALIDATE_EVERY_MS = 60_000; // A role refusal from a real call re-checks at most this often, so a page that // keeps polling a refused route does not re-read /me on every tick. export const ACCESS_RECHECK_MS = 10_000; const TierContext = createContext<TierState>({ identity: null, Loading Loading @@ -153,11 +158,17 @@ export function TierProvider({ children }: { children: ReactNode }) { if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return; void revalidate(); }; const onRefused = () => { if (Date.now() - lastCheck.current < ACCESS_RECHECK_MS) return; void revalidate(); }; window.addEventListener(SESSION_EXPIRED_EVENT, onExpired); window.addEventListener(ACCESS_REFUSED_EVENT, onRefused); window.addEventListener("focus", onReturn); document.addEventListener("visibilitychange", onReturn); return () => { window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired); window.removeEventListener(ACCESS_REFUSED_EVENT, onRefused); window.removeEventListener("focus", onReturn); document.removeEventListener("visibilitychange", onReturn); }; Loading Loading
panel/src/lib/api.test.ts +16 −1 Changes for panel/src/lib/api.test.ts: 16 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -12,7 +12,7 @@ vi.mock("./config", () => ({ })); // Imported after the mock so api.ts picks up the mocked loadConfig. const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } = const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, ACCESS_REFUSED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } = await import("./api"); function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) { Loading Loading @@ -1099,6 +1099,21 @@ describe("session and connection signals", () => { expect(seen).toHaveLength(0); }); it("announces a role refusal, and no other 403", async () => { const seen = listen(ACCESS_REFUSED_EVENT); const refuse = async (status: number, code: string) => { vi.stubGlobal("fetch", fakeFetch({ error: { code, message: "x" } }, { ok: false, status })); await expect(api.myServers()).rejects.toMatchObject({ status, code }); }; for (const code of ["reauth_required", "setup_required", "quota_exceeded", "local_auth_disabled"]) await refuse(403, code); await refuse(409, "forbidden"); expect(seen).toHaveLength(0); await refuse(403, "forbidden"); await refuse(403, "not_admin"); expect(seen).toHaveLength(2); }); it("reports a fetch that got no response, and the next one that did", async () => { const seen = listen(CONNECTION_EVENT); vi.stubGlobal( Loading
panel/src/lib/api.ts +13 −0 Changes for panel/src/lib/api.ts: 13 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -79,6 +79,18 @@ function announceSessionExpired(err: ApiError, path: string): void { window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)); } // A 403 forbidden or not_admin is the role gate refusing the call. The account // may have been demoted since /me was read, which leaves admin pages and buttons // up that every call now refuses. TierProvider hears this and re-reads /me, which // takes them down; a refusal the current role explains changes nothing there. export const ACCESS_REFUSED_EVENT = "felis:access-refused"; const ROLE_REFUSALS = new Set(["forbidden", "not_admin"]); function announceAccessRefused(err: ApiError): void { if (err.status !== 403 || !ROLE_REFUSALS.has(err.code) || typeof window === "undefined") return; window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT)); } // CONNECTION_EVENT reports when requests stop reaching the API (detail.ok = // false) and when one gets through again (true). A fetch that rejects never // saw a response: the network is down, or Cloudflare Access redirected the Loading Loading @@ -178,6 +190,7 @@ function failed(path: string, status: number, statusText: string, text: string): }; announceSetupRequired(err); announceSessionExpired(err, path); announceAccessRefused(err); return err; } Loading
panel/src/lib/tier.test.tsx +45 −2 Changes for panel/src/lib/tier.test.tsx: 45 added lines, 2 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, render, screen } from "@testing-library/react"; import { TierProvider, useTier } from "./tier"; import { ACCESS_RECHECK_MS, TierProvider, useTier } from "./tier"; import { ACCESS_REFUSED_EVENT } from "./api"; import type { Identity } from "./types"; const calls = vi.hoisted(() => ({ me: vi.fn() })); Loading Loading @@ -31,6 +32,9 @@ async function boot() { beforeEach(() => { calls.me.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); describe("TierProvider after a /me that failed with anything but a 401", () => { it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => { Loading Loading @@ -67,3 +71,42 @@ describe("TierProvider after a /me that failed with anything but a 401", () => { expect(tier.unauthenticated).toBe(false); }); }); // An admin demoted while the page is open keeps the admin pages up until /me is // read again; the first refused call does that, at most once per interval. describe("TierProvider after a role refusal", () => { const demoted: Identity = { ...admin, role: "user", is_admin: false }; const refuse = () => act(async () => { window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT)); }); it("re-reads /me and takes the admin view down once the account was demoted", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); calls.me.mockResolvedValue(admin); await boot(); expect(screen.getByText("admin")).toBeTruthy(); calls.me.mockResolvedValue(demoted); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS)); await refuse(); expect(await screen.findByText("not admin")).toBeTruthy(); expect(calls.me).toHaveBeenCalledTimes(2); }); it("re-reads at most once per interval", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); calls.me.mockResolvedValue(admin); await boot(); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2)); await refuse(); expect(calls.me).toHaveBeenCalledTimes(1); await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2)); await refuse(); await refuse(); expect(calls.me).toHaveBeenCalledTimes(2); expect(screen.getByText("admin")).toBeTruthy(); }); });
panel/src/lib/tier.tsx +15 −4 Changes for panel/src/lib/tier.tsx: 15 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import type { Identity } from "./types"; import { api, SESSION_EXPIRED_EVENT } from "./api"; import { ACCESS_REFUSED_EVENT, api, SESSION_EXPIRED_EVENT } from "./api"; import { deriveAuth, isUnauthorized, type AuthState } from "./auth"; // TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing Loading @@ -32,10 +32,12 @@ import { deriveAuth, isUnauthorized, type AuthState } from "./auth"; // re-reads /me after a login / logout so the gate re-evaluates without a reload. // // 4. Session-aware while open: a 401 from any protected call (api.ts announces // SESSION_EXPIRED_EVENT), the tab coming back into view, or the window // SESSION_EXPIRED_EVENT), a role refusal (ACCESS_REFUSED_EVENT, at most once // per ACCESS_RECHECK_MS), the tab coming back into view, or the window // regaining focus re-reads /me through `revalidate()`. It leaves `loading` // alone, so the app stays mounted during the check; only a 401 changes what // is shown, and `sessionEnded` tells the login page why the person landed there. // alone, so the app stays mounted during the check; a 401 signs the person // out, a changed role redraws the gates (a demoted admin loses the admin // pages), and `sessionEnded` tells the login page why the person landed there. // // Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1. Loading @@ -56,6 +58,9 @@ export interface TierState extends AuthState { // A tab returning to view re-checks /me at most this often; a 401 from a real // call re-checks at once. const REVALIDATE_EVERY_MS = 60_000; // A role refusal from a real call re-checks at most this often, so a page that // keeps polling a refused route does not re-read /me on every tick. export const ACCESS_RECHECK_MS = 10_000; const TierContext = createContext<TierState>({ identity: null, Loading Loading @@ -153,11 +158,17 @@ export function TierProvider({ children }: { children: ReactNode }) { if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return; void revalidate(); }; const onRefused = () => { if (Date.now() - lastCheck.current < ACCESS_RECHECK_MS) return; void revalidate(); }; window.addEventListener(SESSION_EXPIRED_EVENT, onExpired); window.addEventListener(ACCESS_REFUSED_EVENT, onRefused); window.addEventListener("focus", onReturn); document.addEventListener("visibilitychange", onReturn); return () => { window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired); window.removeEventListener(ACCESS_REFUSED_EVENT, onRefused); window.removeEventListener("focus", onReturn); document.removeEventListener("visibilitychange", onReturn); }; Loading