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 { ThemeProvider } from "@/lib/theme";
|
||||||
import { TierProvider } from "@/lib/tier";
|
import { TierProvider } from "@/lib/tier";
|
||||||
|
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
@@ -23,6 +25,21 @@ import { UserDetailPage } from "@/pages/admin/UserDetailPage";
|
|||||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||||
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
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):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
// / User-Side — app-tier, every authenticated principal
|
// / User-Side — app-tier, every authenticated principal
|
||||||
// /admin/* Admin-Side — admin-tier, server & content administration
|
// /admin/* Admin-Side — admin-tier, server & content administration
|
||||||
@@ -34,6 +51,7 @@ export default function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TierProvider>
|
<TierProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<SetupRequiredRedirect />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE
|
{/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE
|
||||||
RequireAuth — RequireAuth redirects here — and outside AppShell, so
|
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.
|
// 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 }) {
|
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
||||||
return vi.fn(async () => ({
|
return vi.fn(async () => ({
|
||||||
@@ -663,4 +663,49 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect((opts as RequestInit).method).toBe("DELETE");
|
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> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const { apiBase } = await loadConfig();
|
const { apiBase } = await loadConfig();
|
||||||
const res = await fetch(`${apiBase}${path}`, {
|
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",
|
code: isApiError(parsed) ? parsed.error.code : "error",
|
||||||
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
||||||
};
|
};
|
||||||
|
announceSetupRequired(err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return parsed as T;
|
return parsed as T;
|
||||||
@@ -90,6 +106,7 @@ async function requestRaw<T>(
|
|||||||
code: isApiError(parsed) ? parsed.error.code : "error",
|
code: isApiError(parsed) ? parsed.error.code : "error",
|
||||||
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
||||||
};
|
};
|
||||||
|
announceSetupRequired(err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return parsed as T;
|
return parsed as T;
|
||||||
@@ -501,6 +518,7 @@ export const api = {
|
|||||||
/* non-JSON error body (e.g. an ingress page): keep the status line */
|
/* non-JSON error body (e.g. an ingress page): keep the status line */
|
||||||
}
|
}
|
||||||
const err: ApiError = { status: res.status, code, message };
|
const err: ApiError = { status: res.status, code, message };
|
||||||
|
announceSetupRequired(err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|||||||
Reference in new issue
Block a user