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:
3 files changed
+83
-2
No files matched your search
+19
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user