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.
This commit is contained in:
flyemoji committed 2026-06-27 04:23:32 +09:00
1 parent e108a3709a
commit 885c4a9bd8
12 files changed
+688 -58

No files matched your search

+33 -19
View File
@@ -2,6 +2,9 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { TierProvider } from "@/lib/tier"; import { TierProvider } from "@/lib/tier";
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 { Login } from "@/pages/Login";
import { ChangePassword } from "@/pages/ChangePassword";
import { Dashboard } from "@/pages/Dashboard"; import { Dashboard } from "@/pages/Dashboard";
import { MyServers } from "@/pages/MyServers"; import { MyServers } from "@/pages/MyServers";
import { ServerConsole } from "@/pages/ServerConsole"; import { ServerConsole } from "@/pages/ServerConsole";
@@ -23,27 +26,38 @@ export default function App() {
<TierProvider> <TierProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<AppShell />}> {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
{/* User-Side — app-tier */} RequireAuth — RequireAuth redirects here — and outside AppShell, so
<Route index element={<Dashboard />} /> they render their own centered chrome with no nav/tier dependency. */}
<Route path="servers" element={<MyServers />} /> <Route path="/login" element={<Login />} />
<Route path="servers/:name" element={<ServerConsole />} /> <Route path="/change-password" element={<ChangePassword />} />
<Route path="account" element={<Account />} />
{/* Admin-Side — admin-tier (server & content ops) */} {/* Everything else requires a session. RequireAuth gates the whole app:
<Route path="admin" element={<RequireAdmin />}> no/expired session → /login, forced first-login change →
<Route index element={<AdminHome />} /> /change-password, transient /me failure → still renders (graded ZT). */}
<Route path="servers" element={<ServerAdmin />} /> <Route element={<RequireAuth />}>
<Route path="images" element={<ImageAdmin />} /> <Route element={<AppShell />}>
{/* User-Side — app-tier */}
<Route index element={<Dashboard />} />
<Route path="servers" element={<MyServers />} />
<Route path="servers/:name" element={<ServerConsole />} />
<Route path="account" element={<Account />} />
{/* Admin-Side — admin-tier (server & content ops) */}
<Route path="admin" element={<RequireAdmin />}>
<Route index element={<AdminHome />} />
<Route path="servers" element={<ServerAdmin />} />
<Route path="images" element={<ImageAdmin />} />
</Route>
{/* SysAdmin-Side — admin-tier (platform observability) */}
<Route path="ops" element={<RequireAdmin />}>
<Route index element={<OpsOverview />} />
<Route path="fleet" element={<FleetTable />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
{/* SysAdmin-Side — admin-tier (platform observability) */}
<Route path="ops" element={<RequireAdmin />}>
<Route index element={<OpsOverview />} />
<Route path="fleet" element={<FleetTable />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+28
View File
@@ -0,0 +1,28 @@
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
View File
@@ -0,0 +1,31 @@
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
View File
@@ -62,4 +62,92 @@ describe("api.me wire shape", () => {
expect((opts as RequestInit).method).toBe("GET"); expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include"); 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
View File
@@ -4,6 +4,7 @@ import type {
Identity, Identity,
LinkResult, LinkResult,
LinkStatus, LinkStatus,
LoginResult,
ServerInfo, ServerInfo,
WhitelistImage, WhitelistImage,
} from "./types"; } from "./types";
@@ -46,6 +47,29 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
} }
export const api = { 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 // 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 // (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 // 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 { export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>; const err = e as Partial<ApiError>;
switch (err.code) { 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": case "not_linked":
return "Link your Minecraft account before claiming (Account → Link)."; return "Link your Minecraft account before claiming (Account → Link).";
case "invalid_code": case "invalid_code":
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { deriveAuth, isUnauthorized } from "./auth";
import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
// who is forced through the change-password card, and — critically — who is KEPT in
// the app despite a /me failure. The one distinction that must never blur is a true
// 401 (no session → login) versus any other failure (transient → stay functional),
// because mistaking the latter for the former would log out a healthy Zero-Trust
// principal on a single flaky request. These cases pin every branch.
const admin: Identity = {
user_id: "u1",
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: false,
};
const err401 = { status: 401, code: "unauthorized", message: "no session" };
const err500 = { status: 500, code: "error", message: "boom" };
describe("isUnauthorized", () => {
it("is true only for a 401 envelope", () => {
expect(isUnauthorized(err401)).toBe(true);
});
it("is false for any non-401 failure (transient, 5xx, network)", () => {
expect(isUnauthorized(err500)).toBe(false);
expect(isUnauthorized(new TypeError("Failed to fetch"))).toBe(false);
expect(isUnauthorized(null)).toBe(false);
expect(isUnauthorized(undefined)).toBe(false);
expect(isUnauthorized("nope")).toBe(false);
});
});
describe("deriveAuth", () => {
it("while loading: never unauthenticated, never admin, regardless of error", () => {
const s = deriveAuth(null, err401, true);
expect(s.loading).toBe(true);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
});
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
const s = deriveAuth(null, err401, false);
expect(s.unauthenticated).toBe(true);
expect(s.isAdmin).toBe(false);
});
it("a settled NON-401 failure is NOT unauthenticated (graded ZT stays functional)", () => {
const s = deriveAuth(null, err500, false);
expect(s.unauthenticated).toBe(false);
// identity is null so admin surfaces stay hidden, but the app keeps rendering.
expect(s.isAdmin).toBe(false);
});
it("a loaded admin identity is admin and authenticated", () => {
const s = deriveAuth(admin, null, false);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(true);
expect(s.mustChangePassword).toBe(false);
});
it("surfaces must_change_password from the identity", () => {
const s = deriveAuth({ ...admin, must_change_password: true }, null, false);
expect(s.mustChangePassword).toBe(true);
expect(s.unauthenticated).toBe(false);
});
it("fails closed on a malformed identity missing is_admin / must_change_password", () => {
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
const partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
const s = deriveAuth(partial, null, false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
expect(s.unauthenticated).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { ApiError, Identity } from "./types";
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
// React renderer (mirrors lib/nav.ts). The whole local-password gate turns on one
// distinction the rest of the app routes on: a /me that returns 401 means "there
// is genuinely no session — show the login page", whereas ANY OTHER /me failure
// (network, 5xx, timeout) must NOT log the user out. The latter preserves the
// graded-Zero-Trust contract from tier.tsx rule 2: a principal whose /me momentarily
// fails still gets the User-Side app rather than being bounced to a login form they
// may have no way to satisfy (their real credential is the upstream Access proxy).
/** AuthState is the routing-relevant projection of a single /me outcome. */
export interface AuthState {
/** The resolved identity, or null while loading / on any /me failure. */
identity: Identity | null;
/** True only while the initial /me request is in flight. */
loading: boolean;
/** Server-computed admin flag; false unless a loaded identity says is_admin. */
isAdmin: boolean;
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
* transient or 5xx failure leaves this false so the app keeps rendering. */
unauthenticated: boolean;
/** True when the loaded identity still owes a forced first-login change. */
mustChangePassword: boolean;
}
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
* the one failure mode that means "no session" rather than "session unknown".
* Anything that is not specifically a 401 (network errors, 5xx, a thrown
* non-ApiError) is deliberately treated as NOT unauthenticated. */
export function isUnauthorized(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as Partial<ApiError>).status === 401
);
}
/** deriveAuth folds one /me outcome (identity OR error, plus the in-flight flag)
* into the state the router reads. Every boolean is computed with `=== true` / an
* explicit 401 check so an absent or malformed field fails to the safe side:
* non-admin, still-authenticated, no forced change. */
export function deriveAuth(
identity: Identity | null,
error: unknown,
loading: boolean,
): AuthState {
return {
identity,
loading,
isAdmin: identity?.is_admin === true,
unauthenticated: !loading && identity === null && isUnauthorized(error),
mustChangePassword: identity?.must_change_password === true,
};
}
+53 -34
View File
@@ -1,15 +1,18 @@
import { import {
createContext, createContext,
useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { Identity } from "./types"; import type { Identity } from "./types";
import { api } from "./api"; import { api } from "./api";
import { deriveAuth, type AuthState } from "./auth";
// TierProvider fetches GET /me exactly once at boot and exposes the result through // TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
// context. Two design rules, both load-bearing: // the result through context. Three design rules, all load-bearing:
// //
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in // 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
// flight (`identity === null`) or after it rejects, isAdmin is false — admin // flight (`identity === null`) or after it rejects, isAdmin is false — admin
@@ -17,61 +20,77 @@ import { api } from "./api";
// wire-shape trap: if the JSON ever arrives without `is_admin`, the value is // wire-shape trap: if the JSON ever arrives without `is_admin`, the value is
// `undefined`, which is correctly non-admin rather than a thrown access. // `undefined`, which is correctly non-admin rather than a thrown access.
// //
// 2. Still-functional on failure: a /me rejection is caught and swallowed to a // 2. Still-functional on a transient failure: a /me rejection that is NOT a 401 is
// null identity, NOT re-thrown. A logged-in user whose /me momentarily fails // swallowed to a null identity, NOT re-thrown and NOT treated as logged-out. A
// still gets the full User-Side app; they simply don't see admin surfaces. // principal whose /me momentarily fails still gets the full User-Side app; they
// (The backend 403s admin data calls independently, so this is safe.) // simply don't see admin surfaces. (The backend 403s admin data calls
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
// //
// This is UX truth, not a security control — see DESIGN-WEB-3SIDES §1. // 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
// after a login / change / logout so the gate re-evaluates without a reload.
//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
export interface TierState { export interface TierState extends AuthState {
/** The caller's identity, or null while loading or after a failed /me. */ /** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
identity: Identity | null; * navigation after the context has settled (login → refresh → redirect). */
/** True only while the initial /me request is in flight. */ refresh: () => Promise<void>;
loading: boolean;
/** Server-computed admin flag; false while loading or on failure (fail-closed). */
isAdmin: boolean;
} }
const TierContext = createContext<TierState>({ const TierContext = createContext<TierState>({
identity: null, identity: null,
loading: true, loading: true,
isAdmin: false, isAdmin: false,
unauthenticated: false,
mustChangePassword: false,
refresh: async () => {},
}); });
export function TierProvider({ children }: { children: ReactNode }) { export function TierProvider({ children }: { children: ReactNode }) {
const [identity, setIdentity] = useState<Identity | null>(null); const [identity, setIdentity] = useState<Identity | null>(null);
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { // A monotonic ticket guards against out-of-order refreshes (e.g. a logout's /me
let alive = true; // resolving after a subsequent login's): only the latest call commits its result.
api const seq = useRef(0);
.me()
.then((id) => { const refresh = useCallback(async () => {
if (alive) setIdentity(id); const ticket = ++seq.current;
}) setLoading(true);
.catch(() => { try {
// Fail-closed but functional: stay non-admin, keep rendering User-Side. const id = await api.me();
if (alive) setIdentity(null); if (ticket === seq.current) {
}) setIdentity(id);
.finally(() => { setError(null);
if (alive) setLoading(false); }
}); } catch (e) {
return () => { // Keep the error so deriveAuth can tell a 401 (→ login) from a transient
alive = false; // failure (→ stay functional). Identity is cleared either way.
}; if (ticket === seq.current) {
setIdentity(null);
setError(e);
}
} finally {
if (ticket === seq.current) setLoading(false);
}
}, []); }, []);
const isAdmin = identity?.is_admin === true; useEffect(() => {
void refresh();
}, [refresh]);
const state = deriveAuth(identity, error, loading);
return ( return (
<TierContext.Provider value={{ identity, loading, isAdmin }}> <TierContext.Provider value={{ ...state, refresh }}>
{children} {children}
</TierContext.Provider> </TierContext.Provider>
); );
} }
/** useTier reads the boot-time identity/tier state. */ /** useTier reads the current identity/tier/auth state. */
export function useTier(): TierState { export function useTier(): TierState {
return useContext(TierContext); return useContext(TierContext);
} }
+15
View File
@@ -97,4 +97,19 @@ export interface Identity {
email: string; email: string;
role: "user" | "admin"; role: "user" | "admin";
is_admin: boolean; is_admin: boolean;
/** Local-password path only: the account owes a forced first-login password
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean;
}
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
* session cookie is set as a side effect (HttpOnly, so the panel never sees it);
* the body carries only what the panel routes on next — chiefly whether to force the
* change-password card before any other surface. */
export interface LoginResult {
user_id: string;
role: "user" | "admin";
must_change_password: boolean;
} }
+35 -5
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { CheckCircle2, Link2, ShieldCheck } from "lucide-react"; import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
// The Account page is the web half of the §10 link flow. A code is born in-game // The Account page is the web half of the §10 link flow. A code is born in-game
// (online-mode auth proves the UUID) and consumed here (the session proves the // (online-mode auth proves the UUID) and consumed here (the session proves the
@@ -16,6 +17,24 @@ import { useAsync } from "@/lib/hooks";
export function Account() { export function Account() {
const status = useAsync(() => api.linkStatus(), []); const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier();
// Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
// navigation is needed. (On a Zero-Trust proxied session there is no local cookie
// to drop and /me still succeeds — sign-out is a no-op, which is the honest
// outcome: you cannot sign out of your org's access proxy from here.)
const [signingOut, setSigningOut] = useState(false);
async function signOut() {
if (signingOut) return;
setSigningOut(true);
try {
await api.logout();
} finally {
await refresh();
}
}
// Verify is a mutation, not a read, so it is hand-rolled (the useAsync producer // Verify is a mutation, not a read, so it is hand-rolled (the useAsync producer
// is for the status read). On success we flip linked locally and keep the echoed // is for the status read). On success we flip linked locally and keep the echoed
@@ -60,10 +79,21 @@ export function Account() {
<ShieldCheck className="h-4 w-4 text-primary" /> Session <ShieldCheck className="h-4 w-4 text-primary" /> Session
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground"> <CardContent className="space-y-4 text-sm text-muted-foreground">
You reached this panel through the platform's identity proxy (Zero-Trust / <p>
Access). The panel itself holds no credentials — every request rides your The panel itself holds no credentials — every request rides your existing
existing session cookie (spec §7/§8). session cookie, whether issued by local password sign-in or the platform's
identity proxy (Zero-Trust / Access) (spec §7/§8).
</p>
<Button
variant="outline"
size="sm"
onClick={signOut}
disabled={signingOut}
>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? "Signing out…" : "Sign out"}
</Button>
</CardContent> </CardContent>
</Card> </Card>
+133
View File
@@ -0,0 +1,133 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
// Minimum new-password length. The server is the source of truth (8–72 BYTES, the
// bcrypt limit); this is only a pre-submit courtesy so the obvious case fails
// instantly rather than round-tripping to a `weak_password` error.
const MIN_PASSWORD = 8;
// ChangePassword is the forced first-login change AND the voluntary change surface
// (spec §B1). It lives OUTSIDE RequireAuth on purpose: RequireAuth redirects a
// must-change principal *to* this page, so nesting it under that gate would loop.
// It therefore re-checks auth itself — a 401 principal is sent to /login.
//
// On success the server keeps the caller's own session (revoking only the others),
// so no re-login is needed: we refresh /me — which now reports must_change_password
// false — and continue into the app.
export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) {
return (
<AuthLayout title="Felis">
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
</AuthLayout>
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
const mismatch = confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < MIN_PASSWORD;
const canSubmit =
!submitting &&
current.length > 0 &&
next.length >= MIN_PASSWORD &&
next === confirm;
async function submit(e: FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await api.changePassword(current, next);
await refresh();
navigate("/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
return (
<AuthLayout
title="Set a new password"
subtitle={
mustChangePassword
? "Your account was issued a one-time password. Choose a new one to continue."
: "Update your console password."
}
>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">Current password</Label>
<Input
id="current"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
aria-invalid={tooShort ? true : undefined}
/>
{tooShort && (
<p className="text-xs text-muted-foreground">
At least {MIN_PASSWORD} characters.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
aria-invalid={mismatch ? true : undefined}
/>
{mismatch && (
<p className="text-xs text-destructive">Passwords don't match.</p>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? "Saving…" : "Change password"}
</Button>
</form>
</CardContent>
</Card>
</AuthLayout>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
// Login is the local-password sign-in (spec §B1). It is the ONLY local credential
// surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
// the API sets an HttpOnly session cookie (invisible here); we then refresh the tier
// context so the gate re-evaluates, and route to the forced change-password card
// when the account still owes its first-login change, else to the dashboard.
//
// Reaching this page already-authenticated (e.g. typing /login while signed in)
// short-circuits to the right destination rather than showing the form.
export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Don't flash the form while the boot /me is still in flight: a signed-in visitor
// would briefly see a login form before being redirected away.
if (loading) {
return (
<AuthLayout title="Felis">
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
</AuthLayout>
);
}
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
if (identity) return <Navigate to="/" replace />;
async function submit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.login(username.trim(), password);
// Re-read /me so the context reflects the new session before we leave this
// page; the route we land on is gated on that fresh state.
await refresh();
navigate(res.must_change_password ? "/change-password" : "/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
return (
<AuthLayout title="Sign in to Felis" subtitle="Operator console">
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-invalid={error ? true : undefined}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !username.trim() || !password}
>
{submitting ? "Signing in…" : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</AuthLayout>
);
}