Unverified Commit 43699b46 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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).
parent 854320ac
Loading
Loading
Loading
Loading
+19 −1
Changes for panel/src/App.tsx: 19 added lines, 1 removed line.
Original line number Diff line number Diff line
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
Changes for panel/src/lib/api.test.ts: 46 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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 −0
Changes for panel/src/lib/api.ts: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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();