Loading panel/src/components/RequireAdmin.test.tsx 0 → 100644 +89 −0 Changes for panel/src/components/RequireAdmin.test.tsx: 89 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { humanizeError } from "@/lib/api"; import { RequireAdmin } from "./RequireAdmin"; import { RequireOwner } from "./RequireOwner"; const tier = vi.hoisted(() => ({ loading: false, isAdmin: false, isOwner: false, identityError: null as unknown, revalidate: vi.fn(), })); vi.mock("@/lib/tier", () => ({ useTier: () => tier })); const t = (key: string) => i18next.t(key); const err503 = { status: 503, code: "unavailable", message: "database is restarting" }; function renderAt(path: string) { return render( <MemoryRouter initialEntries={[path]}> <Routes> <Route element={<RequireAdmin />}> <Route path="/admin" element={<p>admin page</p>} /> </Route> <Route element={<RequireOwner />}> <Route path="/admin/users" element={<p>users page</p>} /> </Route> </Routes> </MemoryRouter>, ); } const retryButton = () => screen.getByRole("button", { name: t("common:try_again") }); beforeEach(() => { Object.assign(tier, { loading: false, isAdmin: false, isOwner: false, identityError: null }); tier.revalidate.mockReset(); tier.revalidate.mockResolvedValue(undefined); }); describe.each([ ["RequireAdmin", "/admin", "admin page", "isAdmin"], ["RequireOwner", "/admin/users", "users page", "isOwner"], ] as const)("%s", (_name, path, page, flag) => { it("says the access check failed, with why, when /me failed with anything but a 401", () => { tier.identityError = err503; renderAt(path); expect(screen.getByText(t("common:access_unknown_title"))).toBeTruthy(); expect(screen.getByRole("alert").textContent).toBe(humanizeError(err503)); expect(retryButton()).toBeTruthy(); expect(screen.queryByText(t("common:not_authorized_title"))).toBeNull(); expect(screen.queryByText(page)).toBeNull(); }); it("re-reads /me in place from the retry, and holds the button until it answers", async () => { tier.identityError = err503; let answer!: () => void; tier.revalidate.mockReturnValue(new Promise<void>((resolve) => (answer = resolve))); renderAt(path); fireEvent.click(retryButton()); expect(tier.revalidate).toHaveBeenCalledTimes(1); expect((retryButton() as HTMLButtonElement).disabled).toBe(true); fireEvent.click(retryButton()); expect(tier.revalidate).toHaveBeenCalledTimes(1); await act(async () => answer()); expect((retryButton() as HTMLButtonElement).disabled).toBe(false); }); it("still says not authorized to a known account without the role", () => { renderAt(path); expect(screen.getByText(t("common:not_authorized_title"))).toBeTruthy(); expect(screen.queryByText(t("common:access_unknown_title"))).toBeNull(); }); it("renders the page for an account with the role", () => { tier[flag] = true; renderAt(path); expect(screen.getByText(page)).toBeTruthy(); }); }); panel/src/components/RequireAdmin.tsx +6 −4 Changes for panel/src/components/RequireAdmin.tsx: 6 added lines, 4 removed lines. Original line number Diff line number Diff line import { Outlet } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useTier } from "@/lib/tier"; import { Loading, NotAuthorized } from "@/components/States"; import { AccessUnknown, Loading, NotAuthorized } from "@/components/States"; // RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two // admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of // 403s, but the server enforces the boundary independently on every data call. // // While /me is still loading we show a spinner rather than NotAuthorized, so a // genuine admin is never briefly told "not authorized" on a slow boot. Once // settled, isAdmin is the fail-closed verdict (false on any /me failure). // genuine admin is never briefly told "not authorized" on a slow boot. A /me that // failed with anything but a 401 leaves the answer unknown, so the page says the // check failed and offers a retry. Otherwise isAdmin is the fail-closed verdict. export function RequireAdmin() { const { isAdmin, loading } = useTier(); const { isAdmin, loading, identityError } = useTier(); const { t } = useTranslation("common"); if (loading) return <Loading label={t("checking_access")} />; if (identityError) return <AccessUnknown error={identityError} />; return isAdmin ? <Outlet /> : <NotAuthorized />; } panel/src/components/RequireOwner.tsx +8 −3 Changes for panel/src/components/RequireOwner.tsx: 8 added lines, 3 removed lines. Original line number Diff line number Diff line import { Outlet } from "react-router-dom"; import { useTier } from "@/lib/tier"; import { NotAuthorized } from "@/components/States"; import { Loading } from "@/components/States"; import { AccessUnknown, Loading, NotAuthorized } from "@/components/States"; // RequireOwner gates user management, one level above admin. Like RequireAdmin // it waits for /me, and a /me that failed with anything but a 401 gets a retry: // the owner is not told "not authorized" on a flaky read. export function RequireOwner() { const { loading, isOwner } = useTier(); const { loading, isOwner, identityError } = useTier(); if (loading) { return <Loading />; } if (identityError) { return <AccessUnknown error={identityError} />; } if (!isOwner) { return <NotAuthorized />; } Loading panel/src/components/States.tsx +39 −1 Changes for panel/src/components/States.tsx: 39 added lines, 1 removed line. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { useState } from "react"; import { Loader2, AlertTriangle, Inbox, ShieldX, ShieldQuestion, Construction, Moon, SearchX, RefreshCw } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge"; import { PowerButton } from "@/components/PowerButton"; import { Button } from "@/components/ui/button"; import { humanizeError } from "@/lib/api"; import { useTier } from "@/lib/tier"; import type { Phase, RetireState } from "@/lib/types"; import { cn } from "@/lib/utils"; Loading Loading @@ -94,6 +97,41 @@ export function NotAuthorized() { ); } // AccessUnknown stands in for an admin or owner page while /me has failed with // anything but a 401: whether this account may see the page is unknown, so it // says the check failed and why, and retries in place (the app stays mounted). export function AccessUnknown({ error }: { error: unknown }) { const { t } = useTranslation("common"); const { revalidate } = useTier(); const [retrying, setRetrying] = useState(false); async function retry() { setRetrying(true); try { await revalidate(); } finally { setRetrying(false); } } return ( <div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <ShieldQuestion className="h-8 w-8 text-amber-500" /> <div> <p className="font-medium">{t("access_unknown_title")}</p> <p className="mt-1 text-sm text-muted-foreground">{t("access_unknown_body")}</p> <p role="alert" className="mt-2 text-xs text-muted-foreground/80"> {humanizeError(error)} </p> </div> <Button size="sm" variant="outline" onClick={() => void retry()} disabled={retrying}> {retrying ? <Loader2 className="animate-spin" /> : <RefreshCw />} {t("try_again")} </Button> </div> ); } export interface NotFoundProps { title?: string; body?: string; Loading panel/src/i18n/resources/en-US/common.json +2 −0 Changes for panel/src/i18n/resources/en-US/common.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -12,6 +12,8 @@ "not_wired_yet": "Not wired yet", "pending_backend_prefix": "pending backend: ", "checking_access": "Checking access…", "access_unknown_title": "Couldn't check your access", "access_unknown_body": "Your account details didn't load, so this page stays hidden until they do.", "loading_config": "Loading config…", "brand_name": "Felis", "brand_tagline": "K8s-native Minecraft orchestration", Loading Loading
panel/src/components/RequireAdmin.test.tsx 0 → 100644 +89 −0 Changes for panel/src/components/RequireAdmin.test.tsx: 89 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { humanizeError } from "@/lib/api"; import { RequireAdmin } from "./RequireAdmin"; import { RequireOwner } from "./RequireOwner"; const tier = vi.hoisted(() => ({ loading: false, isAdmin: false, isOwner: false, identityError: null as unknown, revalidate: vi.fn(), })); vi.mock("@/lib/tier", () => ({ useTier: () => tier })); const t = (key: string) => i18next.t(key); const err503 = { status: 503, code: "unavailable", message: "database is restarting" }; function renderAt(path: string) { return render( <MemoryRouter initialEntries={[path]}> <Routes> <Route element={<RequireAdmin />}> <Route path="/admin" element={<p>admin page</p>} /> </Route> <Route element={<RequireOwner />}> <Route path="/admin/users" element={<p>users page</p>} /> </Route> </Routes> </MemoryRouter>, ); } const retryButton = () => screen.getByRole("button", { name: t("common:try_again") }); beforeEach(() => { Object.assign(tier, { loading: false, isAdmin: false, isOwner: false, identityError: null }); tier.revalidate.mockReset(); tier.revalidate.mockResolvedValue(undefined); }); describe.each([ ["RequireAdmin", "/admin", "admin page", "isAdmin"], ["RequireOwner", "/admin/users", "users page", "isOwner"], ] as const)("%s", (_name, path, page, flag) => { it("says the access check failed, with why, when /me failed with anything but a 401", () => { tier.identityError = err503; renderAt(path); expect(screen.getByText(t("common:access_unknown_title"))).toBeTruthy(); expect(screen.getByRole("alert").textContent).toBe(humanizeError(err503)); expect(retryButton()).toBeTruthy(); expect(screen.queryByText(t("common:not_authorized_title"))).toBeNull(); expect(screen.queryByText(page)).toBeNull(); }); it("re-reads /me in place from the retry, and holds the button until it answers", async () => { tier.identityError = err503; let answer!: () => void; tier.revalidate.mockReturnValue(new Promise<void>((resolve) => (answer = resolve))); renderAt(path); fireEvent.click(retryButton()); expect(tier.revalidate).toHaveBeenCalledTimes(1); expect((retryButton() as HTMLButtonElement).disabled).toBe(true); fireEvent.click(retryButton()); expect(tier.revalidate).toHaveBeenCalledTimes(1); await act(async () => answer()); expect((retryButton() as HTMLButtonElement).disabled).toBe(false); }); it("still says not authorized to a known account without the role", () => { renderAt(path); expect(screen.getByText(t("common:not_authorized_title"))).toBeTruthy(); expect(screen.queryByText(t("common:access_unknown_title"))).toBeNull(); }); it("renders the page for an account with the role", () => { tier[flag] = true; renderAt(path); expect(screen.getByText(page)).toBeTruthy(); }); });
panel/src/components/RequireAdmin.tsx +6 −4 Changes for panel/src/components/RequireAdmin.tsx: 6 added lines, 4 removed lines. Original line number Diff line number Diff line import { Outlet } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useTier } from "@/lib/tier"; import { Loading, NotAuthorized } from "@/components/States"; import { AccessUnknown, Loading, NotAuthorized } from "@/components/States"; // RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two // admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of // 403s, but the server enforces the boundary independently on every data call. // // While /me is still loading we show a spinner rather than NotAuthorized, so a // genuine admin is never briefly told "not authorized" on a slow boot. Once // settled, isAdmin is the fail-closed verdict (false on any /me failure). // genuine admin is never briefly told "not authorized" on a slow boot. A /me that // failed with anything but a 401 leaves the answer unknown, so the page says the // check failed and offers a retry. Otherwise isAdmin is the fail-closed verdict. export function RequireAdmin() { const { isAdmin, loading } = useTier(); const { isAdmin, loading, identityError } = useTier(); const { t } = useTranslation("common"); if (loading) return <Loading label={t("checking_access")} />; if (identityError) return <AccessUnknown error={identityError} />; return isAdmin ? <Outlet /> : <NotAuthorized />; }
panel/src/components/RequireOwner.tsx +8 −3 Changes for panel/src/components/RequireOwner.tsx: 8 added lines, 3 removed lines. Original line number Diff line number Diff line import { Outlet } from "react-router-dom"; import { useTier } from "@/lib/tier"; import { NotAuthorized } from "@/components/States"; import { Loading } from "@/components/States"; import { AccessUnknown, Loading, NotAuthorized } from "@/components/States"; // RequireOwner gates user management, one level above admin. Like RequireAdmin // it waits for /me, and a /me that failed with anything but a 401 gets a retry: // the owner is not told "not authorized" on a flaky read. export function RequireOwner() { const { loading, isOwner } = useTier(); const { loading, isOwner, identityError } = useTier(); if (loading) { return <Loading />; } if (identityError) { return <AccessUnknown error={identityError} />; } if (!isOwner) { return <NotAuthorized />; } Loading
panel/src/components/States.tsx +39 −1 Changes for panel/src/components/States.tsx: 39 added lines, 1 removed line. Original line number Diff line number Diff line import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { useState } from "react"; import { Loader2, AlertTriangle, Inbox, ShieldX, ShieldQuestion, Construction, Moon, SearchX, RefreshCw } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge"; import { PowerButton } from "@/components/PowerButton"; import { Button } from "@/components/ui/button"; import { humanizeError } from "@/lib/api"; import { useTier } from "@/lib/tier"; import type { Phase, RetireState } from "@/lib/types"; import { cn } from "@/lib/utils"; Loading Loading @@ -94,6 +97,41 @@ export function NotAuthorized() { ); } // AccessUnknown stands in for an admin or owner page while /me has failed with // anything but a 401: whether this account may see the page is unknown, so it // says the check failed and why, and retries in place (the app stays mounted). export function AccessUnknown({ error }: { error: unknown }) { const { t } = useTranslation("common"); const { revalidate } = useTier(); const [retrying, setRetrying] = useState(false); async function retry() { setRetrying(true); try { await revalidate(); } finally { setRetrying(false); } } return ( <div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <ShieldQuestion className="h-8 w-8 text-amber-500" /> <div> <p className="font-medium">{t("access_unknown_title")}</p> <p className="mt-1 text-sm text-muted-foreground">{t("access_unknown_body")}</p> <p role="alert" className="mt-2 text-xs text-muted-foreground/80"> {humanizeError(error)} </p> </div> <Button size="sm" variant="outline" onClick={() => void retry()} disabled={retrying}> {retrying ? <Loader2 className="animate-spin" /> : <RefreshCw />} {t("try_again")} </Button> </div> ); } export interface NotFoundProps { title?: string; body?: string; Loading
panel/src/i18n/resources/en-US/common.json +2 −0 Changes for panel/src/i18n/resources/en-US/common.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -12,6 +12,8 @@ "not_wired_yet": "Not wired yet", "pending_backend_prefix": "pending backend: ", "checking_access": "Checking access…", "access_unknown_title": "Couldn't check your access", "access_unknown_body": "Your account details didn't load, so this page stays hidden until they do.", "loading_config": "Loading config…", "brand_name": "Felis", "brand_tagline": "K8s-native Minecraft orchestration", Loading