Unverified Commit 885c4a9b authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(panel): local-password login and forced password change

Add the op.console login and forced first-login password-change flow to
the panel. RequireAuth bounces an unauthenticated visitor to /login;
both /login and /change-password render outside the app shell with their
own centered chrome.

- TierProvider now derives auth state (deriveAuth) and exposes refresh()
  so a successful login re-fetches identity without a full reload; only a
  genuine 401 marks the session unauthenticated, so a transient /me
  failure keeps a healthy Zero-Trust principal in the app.
- api.login/logout/changePassword send Content-Type: application/json on
  bodied requests to satisfy the backend guard; humanizeError maps the
  auth error codes to stable copy.

Covered by vitest unit tests for deriveAuth branch coverage and the
login/change-password wire-shape contracts.
parent e108a370
Loading
Loading
Loading
Loading
+14 −0
Changes for panel/src/App.tsx: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,9 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { TierProvider } from "@/lib/tier";
import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { Login } from "@/pages/Login";
import { ChangePassword } from "@/pages/ChangePassword";
import { Dashboard } from "@/pages/Dashboard";
import { MyServers } from "@/pages/MyServers";
import { ServerConsole } from "@/pages/ServerConsole";
@@ -23,6 +26,16 @@ export default function App() {
    <TierProvider>
      <BrowserRouter>
        <Routes>
          {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
              RequireAuth — RequireAuth redirects here — and outside AppShell, so
              they render their own centered chrome with no nav/tier dependency. */}
          <Route path="/login" element={<Login />} />
          <Route path="/change-password" element={<ChangePassword />} />

          {/* Everything else requires a session. RequireAuth gates the whole app:
              no/expired session → /login, forced first-login change →
              /change-password, transient /me failure → still renders (graded ZT). */}
          <Route element={<RequireAuth />}>
            <Route element={<AppShell />}>
              {/* User-Side — app-tier */}
              <Route index element={<Dashboard />} />
@@ -45,6 +58,7 @@ export default function App() {

              <Route path="*" element={<Navigate to="/" replace />} />
            </Route>
          </Route>
        </Routes>
      </BrowserRouter>
    </TierProvider>
+28 −0
Changes for panel/src/components/AuthLayout.tsx: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { ReactNode } from "react";
import { Cat } from "lucide-react";

// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
// so they get their own centered, branded frame rather than the sidebar layout.
export function AuthLayout({
  title,
  subtitle,
  children,
}: {
  title: string;
  subtitle?: string;
  children: ReactNode;
}) {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
      <div className="w-full max-w-sm space-y-6">
        <div className="flex flex-col items-center gap-2 text-center">
          <Cat className="h-9 w-9 text-primary" />
          <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
          {subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
        </div>
        {children}
      </div>
    </div>
  );
}
+31 −0
Changes for panel/src/components/RequireAuth.tsx: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Navigate, Outlet } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTier } from "@/lib/tier";

// RequireAuth is the layout gate in front of the whole authenticated app (AppShell
// and everything under it). It encodes the three-way verdict from the tier model:
//
//   loading            → a full-screen spinner (never flash login during boot /me)
//   unauthenticated    → /login   (a genuine 401: no/expired session)
//   mustChangePassword → /change-password   (forced first-login change)
//   otherwise          → render the app (<Outlet/>)
//
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
// (a transient/5xx /me failure leaves identity null but unauthenticated false): the
// app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
  const { loading, unauthenticated, mustChangePassword } = useTier();

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground">
        <Loader2 className="h-4 w-4 animate-spin" />
        Loading…
      </div>
    );
  }
  if (unauthenticated) return <Navigate to="/login" replace />;
  if (mustChangePassword) return <Navigate to="/change-password" replace />;
  return <Outlet />;
}
+88 −0
Changes for panel/src/lib/api.test.ts: 88 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -62,4 +62,92 @@ describe("api.me wire shape", () => {
    expect((opts as RequestInit).method).toBe("GET");
    expect((opts as RequestInit).credentials).toBe("include");
  });

  it("surfaces must_change_password from GET /me verbatim", async () => {
    // handleMe always emits must_change_password; the forced-change gate routes on
    // it, so the snake_case key must survive the untyped boundary unchanged.
    const body = {
      user_id: "u4",
      email: "[email protected]",
      role: "admin",
      is_admin: true,
      must_change_password: true,
    };
    vi.stubGlobal("fetch", fakeFetch(body));
    const id = await api.me();
    expect(id.must_change_password).toBe(true);
  });
});

describe("local-password auth wire shapes", () => {
  beforeEach(() => vi.restoreAllMocks());
  afterEach(() => vi.unstubAllGlobals());

  it("login POSTs {username, password} and returns must_change_password", async () => {
    // EXACTLY handlers_auth.go handleLogin's request body and response.
    const fetchSpy = fakeFetch({
      user_id: "u1",
      role: "admin",
      must_change_password: true,
    });
    vi.stubGlobal("fetch", fetchSpy);
    const res = await api.login("owner", "s3cret");
    expect(res.must_change_password).toBe(true);
    expect(res.user_id).toBe("u1");

    const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
      .calls[0];
    expect(String(url)).toBe("/auth/login");
    expect((opts as RequestInit).method).toBe("POST");
    expect((opts as RequestInit).credentials).toBe("include");
    // The Go login route now REQUIRES Content-Type: application/json (it 415s any
    // other type to kill the cross-site form-POST forgery vector). This pins the
    // panel half of that contract: a refactor that drops the header silently breaks
    // login, and only this assertion would catch it.
    expect((opts as RequestInit).headers).toEqual({
      "Content-Type": "application/json",
    });
    expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
      username: "owner",
      password: "s3cret",
    });
  });

  it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
    const fetchSpy = fakeFetch({ ok: true });
    vi.stubGlobal("fetch", fetchSpy);
    const res = await api.logout();
    expect(res.ok).toBe(true);
    const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
      .calls[0];
    expect(String(url)).toBe("/auth/logout");
    expect((opts as RequestInit).method).toBe("POST");
  });

  it("changePassword POSTs {current_password, new_password}", async () => {
    const fetchSpy = fakeFetch({ ok: true });
    vi.stubGlobal("fetch", fetchSpy);
    await api.changePassword("old-pw", "brand-new-pw");
    const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
      .calls[0];
    expect(String(url)).toBe("/auth/change-password");
    expect((opts as RequestInit).method).toBe("POST");
    // Same JSON content-type contract as login — the change-password route guards on
    // it too (defense-in-depth), so the panel must keep sending it.
    expect((opts as RequestInit).headers).toEqual({
      "Content-Type": "application/json",
    });
    expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
      current_password: "old-pw",
      new_password: "brand-new-pw",
    });
  });

  it("maps the auth error codes to stable human copy", async () => {
    const { humanizeError } = await import("./api");
    expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
    expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
    expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
    expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
  });
});
+33 −0
Changes for panel/src/lib/api.ts: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import type {
  Identity,
  LinkResult,
  LinkStatus,
  LoginResult,
  ServerInfo,
  WhitelistImage,
} from "./types";
@@ -46,6 +47,29 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
}

export const api = {
  // Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
  // side effect — the panel never sees it — and returns only what to route on next
  // (must_change_password forces the change card before any other surface). The
  // username/password pair is the ONLY local credential; Passkey/PWA are Phase
  // B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment.
  login: (username: string, password: string) =>
    request<LoginResult>("POST", "/auth/login", { username, password }),

  // logout is idempotent server-side (clears the session row + cookie); calling it
  // without a session still resolves 200. After it, refreshing /me yields 401, which
  // the tier model reads as `unauthenticated` and routes back to /login.
  logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),

  // changePassword is callable during the first-login lockdown (the route is
  // AllowDuringPasswordChange): the server re-verifies current_password, rejects an
  // unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
  // every OTHER session. The caller's own session is kept, so no re-login is needed.
  changePassword: (current_password: string, new_password: string) =>
    request<{ ok: boolean }>("POST", "/auth/change-password", {
      current_password,
      new_password,
    }),

  // Identity (spec §7 GET /me) — the tier keystone. is_admin is server-computed
  // (Principal.IsAdmin); the panel reads it but re-deriving admin-ness is the
  // backend's job. Drives nav + route guards only; every admin route 403s on its
@@ -103,6 +127,15 @@ export function consoleStreamURL(apiBase: string, name: string): string {
export function humanizeError(e: unknown): string {
  const err = e as Partial<ApiError>;
  switch (err.code) {
    // Local-password auth (spec §B1).
    case "local_auth_disabled":
      return "Password sign-in is turned off here — reach this console through your organization's secure access.";
    case "invalid_credentials":
      return "Incorrect username or password.";
    case "weak_password":
      return "Pick a password between 8 and 72 characters.";
    case "password_unchanged":
      return "Your new password must differ from the current one.";
    case "not_linked":
      return "Link your Minecraft account before claiming (Account → Link).";
    case "invalid_code":
Loading