From 43699b46db55531eff3bead9ea2b96e7071bd78a Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 10:32:38 +0800 Subject: [PATCH] fix(panel): route a setup-locked session to the wizard, not a permission error (tracker #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that still owes forced onboarding gets 403 setup_required from every protected route, but the panel rendered it as the generic forbidden line — the one step that unlocks the app read as missing authorization. api.ts now announces the code on a window event (client module has no router) and the App shell, inside the Router, navigates to /setup; the wizard resumes from the surviving session with or without a token. Other 403s are untouched. Panel tests: +2 (fires on setup_required, silent on any other 403). --- panel/src/App.tsx | 20 ++++++++++++++++- panel/src/lib/api.test.ts | 47 ++++++++++++++++++++++++++++++++++++++- panel/src/lib/api.ts | 18 +++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 640f9ad..d27c5d1 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -1,6 +1,8 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { useEffect } from "react"; +import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom"; import { ThemeProvider } from "@/lib/theme"; import { TierProvider } from "@/lib/tier"; +import { SETUP_REQUIRED_EVENT } from "@/lib/api"; import { AppShell } from "@/components/AppShell"; import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAuth } from "@/components/RequireAuth"; @@ -23,6 +25,21 @@ import { UserDetailPage } from "@/pages/admin/UserDetailPage"; import { MySubmissionsPage } from "@/pages/MySubmissionsPage"; import { UpdatesPage } from "@/pages/admin/UpdatesPage"; +// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits +// when a session still owes forced onboarding (#8) and routes it to the wizard. +// It must live inside the Router (it navigates) and outside RequireAuth (/setup +// sits there too); the event fires from any protected call the app makes, so the +// listener is always mounted by the time one arrives. +function SetupRequiredRedirect() { + const navigate = useNavigate(); + useEffect(() => { + const toSetup = () => navigate("/setup", { replace: true }); + window.addEventListener(SETUP_REQUIRED_EVENT, toSetup); + return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup); + }, [navigate]); + return null; +} + // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal // /admin/* Admin-Side — admin-tier, server & content administration @@ -34,6 +51,7 @@ export default function App() { + {/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE RequireAuth — RequireAuth redirects here — and outside AppShell, so diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 6cf5ad4..5cfe54c 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -12,7 +12,7 @@ vi.mock("./config", () => ({ })); // Imported after the mock so api.ts picks up the mocked loadConfig. -const { api } = await import("./api"); +const { api, SETUP_REQUIRED_EVENT } = await import("./api"); function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) { return vi.fn(async () => ({ @@ -663,4 +663,49 @@ describe("image whitelist and builds wire shapes", () => { expect((opts as RequestInit).method).toBe("DELETE"); }); }); + + // #8: a locked session gets `403 setup_required` on every protected route. The + // panel must not render that as a permission error — it announces the code so + // App.tsx can route the person to /setup. These tests pin the announcement + // (fire on the exact code, stay silent on any other 403) because a regression + // here reappears as the confusing "无权执行此操作" report, with nothing failing. + describe("setup_required routing signal", () => { + it("emits SETUP_REQUIRED_EVENT for a 403 setup_required", async () => { + const target = new EventTarget(); + vi.stubGlobal("window", target); + let hits = 0; + target.addEventListener(SETUP_REQUIRED_EVENT, () => { + hits += 1; + }); + vi.stubGlobal( + "fetch", + fakeFetch( + { + error: { + code: "setup_required", + message: "passkey enrollment is required before this action is available", + }, + }, + { ok: false, status: 403 }, + ), + ); + await expect(api.me()).rejects.toMatchObject({ status: 403, code: "setup_required" }); + expect(hits).toBe(1); + }); + + it("stays silent for an ordinary 403", async () => { + const target = new EventTarget(); + vi.stubGlobal("window", target); + let hits = 0; + target.addEventListener(SETUP_REQUIRED_EVENT, () => { + hits += 1; + }); + vi.stubGlobal( + "fetch", + fakeFetch({ error: { code: "forbidden", message: "no" } }, { ok: false, status: 403 }), + ); + await expect(api.me()).rejects.toMatchObject({ status: 403, code: "forbidden" }); + expect(hits).toBe(0); + }); + }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 296594e..ccf0e7f 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -44,6 +44,21 @@ function isApiError(x: unknown): x is { error: { code: string; message: string } ); } +// A locked session — one that still owes the forced onboarding (passkey +// enrollment) — gets `403 setup_required` from every protected route. Rendered as +// a generic permission error that reads as "you may not do this", when the truth +// is "one step remains and completing it unlocks the app" (#8). api.ts cannot +// navigate (no router here), so it announces the code on a window event; the +// App-shell listener routes the person to /setup, which resumes from the session +// without needing a token. Non-browser callers keep the plain error. +export const SETUP_REQUIRED_EVENT = "felis:setup-required"; + +function announceSetupRequired(err: ApiError): void { + if (err.status !== 403 || err.code !== "setup_required") return; + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT)); +} + async function request(method: string, path: string, body?: unknown): Promise { const { apiBase } = await loadConfig(); const res = await fetch(`${apiBase}${path}`, { @@ -62,6 +77,7 @@ async function request(method: string, path: string, body?: unknown): Promise code: isApiError(parsed) ? parsed.error.code : "error", message: isApiError(parsed) ? parsed.error.message : res.statusText, }; + announceSetupRequired(err); throw err; } return parsed as T; @@ -90,6 +106,7 @@ async function requestRaw( code: isApiError(parsed) ? parsed.error.code : "error", message: isApiError(parsed) ? parsed.error.message : res.statusText, }; + announceSetupRequired(err); throw err; } return parsed as T; @@ -501,6 +518,7 @@ export const api = { /* non-JSON error body (e.g. an ingress page): keep the status line */ } const err: ApiError = { status: res.status, code, message }; + announceSetupRequired(err); throw err; } const blob = await res.blob();