fix(panel): route a setup-locked session to the wizard, not a permission error (tracker #8)

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).
This commit is contained in:
Lemon-miaow committed 2026-09-24 10:32:38 +08:00
1 parent 854320ac3f
commit 43699b46db
3 files changed
+83 -2

No files matched your search

+19 -1
View File
@@ -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() {
<ThemeProvider>
<TierProvider>
<BrowserRouter>
<SetupRequiredRedirect />
<Routes>
{/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE
RequireAuth — RequireAuth redirects here — and outside AppShell, so
+46 -1
View File
@@ -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);
});
});
});
+18
View File
@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}${path}`, {
@@ -62,6 +77,7 @@ async function request<T>(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<T>(
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();