diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 401e4aa..b3dced4 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -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,27 +26,38 @@ export default function App() { - }> - {/* User-Side — app-tier */} - } /> - } /> - } /> - } /> + {/* 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. */} + } /> + } /> - {/* Admin-Side — admin-tier (server & content ops) */} - }> - } /> - } /> - } /> + {/* 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). */} + }> + }> + {/* User-Side — app-tier */} + } /> + } /> + } /> + } /> + + {/* Admin-Side — admin-tier (server & content ops) */} + }> + } /> + } /> + } /> + + + {/* SysAdmin-Side — admin-tier (platform observability) */} + }> + } /> + } /> + + + } /> - - {/* SysAdmin-Side — admin-tier (platform observability) */} - }> - } /> - } /> - - - } /> diff --git a/panel/src/components/AuthLayout.tsx b/panel/src/components/AuthLayout.tsx new file mode 100644 index 0000000..0b900b5 --- /dev/null +++ b/panel/src/components/AuthLayout.tsx @@ -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 ( +
+
+
+ +

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+
+ ); +} diff --git a/panel/src/components/RequireAuth.tsx b/panel/src/components/RequireAuth.tsx new file mode 100644 index 0000000..63b39c4 --- /dev/null +++ b/panel/src/components/RequireAuth.tsx @@ -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 () +// +// 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 ( +
+ + Loading… +
+ ); + } + if (unauthenticated) return ; + if (mustChangePassword) return ; + return ; +} diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 1e27152..41d24fe 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -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: "o@p.q", + 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).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).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).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); + }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 8b92719..d519397 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -4,6 +4,7 @@ import type { Identity, LinkResult, LinkStatus, + LoginResult, ServerInfo, WhitelistImage, } from "./types"; @@ -46,6 +47,29 @@ async function request(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("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; 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": diff --git a/panel/src/lib/auth.test.ts b/panel/src/lib/auth.test.ts new file mode 100644 index 0000000..ed1e516 --- /dev/null +++ b/panel/src/lib/auth.test.ts @@ -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: "a@b.c", + 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); + }); +}); diff --git a/panel/src/lib/auth.ts b/panel/src/lib/auth.ts new file mode 100644 index 0000000..b851629 --- /dev/null +++ b/panel/src/lib/auth.ts @@ -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).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, + }; +} diff --git a/panel/src/lib/tier.tsx b/panel/src/lib/tier.tsx index 9bc0d84..bf62ebb 100644 --- a/panel/src/lib/tier.tsx +++ b/panel/src/lib/tier.tsx @@ -1,15 +1,18 @@ import { createContext, + useCallback, useContext, useEffect, + useRef, useState, type ReactNode, } from "react"; import type { Identity } from "./types"; import { api } from "./api"; +import { deriveAuth, type AuthState } from "./auth"; -// TierProvider fetches GET /me exactly once at boot and exposes the result through -// context. Two design rules, both load-bearing: +// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing +// the result through context. Three design rules, all load-bearing: // // 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in // 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 // `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 -// null identity, NOT re-thrown. A logged-in user whose /me momentarily fails -// still gets the full User-Side app; they simply don't see admin surfaces. -// (The backend 403s admin data calls independently, so this is safe.) +// 2. Still-functional on a transient failure: a /me rejection that is NOT a 401 is +// swallowed to a null identity, NOT re-thrown and NOT treated as logged-out. A +// principal whose /me momentarily fails still gets the full User-Side app; they +// 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 { - /** The caller's identity, or null while loading or after a failed /me. */ - identity: Identity | null; - /** True only while the initial /me request is in flight. */ - loading: boolean; - /** Server-computed admin flag; false while loading or on failure (fail-closed). */ - isAdmin: boolean; +export interface TierState extends AuthState { + /** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a + * navigation after the context has settled (login → refresh → redirect). */ + refresh: () => Promise; } const TierContext = createContext({ identity: null, loading: true, isAdmin: false, + unauthenticated: false, + mustChangePassword: false, + refresh: async () => {}, }); export function TierProvider({ children }: { children: ReactNode }) { const [identity, setIdentity] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - let alive = true; - api - .me() - .then((id) => { - if (alive) setIdentity(id); - }) - .catch(() => { - // Fail-closed but functional: stay non-admin, keep rendering User-Side. - if (alive) setIdentity(null); - }) - .finally(() => { - if (alive) setLoading(false); - }); - return () => { - alive = false; - }; + // A monotonic ticket guards against out-of-order refreshes (e.g. a logout's /me + // resolving after a subsequent login's): only the latest call commits its result. + const seq = useRef(0); + + const refresh = useCallback(async () => { + const ticket = ++seq.current; + setLoading(true); + try { + const id = await api.me(); + if (ticket === seq.current) { + setIdentity(id); + setError(null); + } + } catch (e) { + // Keep the error so deriveAuth can tell a 401 (→ login) from a transient + // 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 ( - + {children} ); } -/** useTier reads the boot-time identity/tier state. */ +/** useTier reads the current identity/tier/auth state. */ export function useTier(): TierState { return useContext(TierContext); } diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 560de43..03b2451 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -97,4 +97,19 @@ export interface Identity { email: string; role: "user" | "admin"; 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; } diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index 70da1b2..f42de07 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -1,5 +1,5 @@ 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 { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import { Loading, ErrorState } from "@/components/States"; import { api, humanizeError } from "@/lib/api"; 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 // (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() { 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 // is for the status read). On success we flip linked locally and keep the echoed @@ -60,10 +79,21 @@ export function Account() { Session - - You reached this panel through the platform's identity proxy (Zero-Trust / - Access). The panel itself holds no credentials — every request rides your - existing session cookie (spec §7/§8). + +

+ The panel itself holds no credentials — every request rides your existing + session cookie, whether issued by local password sign-in or the platform's + identity proxy (Zero-Trust / Access) (spec §7/§8). +

+
diff --git a/panel/src/pages/ChangePassword.tsx b/panel/src/pages/ChangePassword.tsx new file mode 100644 index 0000000..6376b9f --- /dev/null +++ b/panel/src/pages/ChangePassword.tsx @@ -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(null); + + if (loading) { + return ( + +
+ + Loading… +
+
+ ); + } + if (unauthenticated) return ; + + 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 ( + + + +
+
+ + setCurrent(e.target.value)} + autoComplete="current-password" + autoFocus + aria-invalid={error ? true : undefined} + /> +
+
+ + setNext(e.target.value)} + autoComplete="new-password" + aria-invalid={tooShort ? true : undefined} + /> + {tooShort && ( +

+ At least {MIN_PASSWORD} characters. +

+ )} +
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + aria-invalid={mismatch ? true : undefined} + /> + {mismatch && ( +

Passwords don't match.

+ )} +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx new file mode 100644 index 0000000..69d2e6b --- /dev/null +++ b/panel/src/pages/Login.tsx @@ -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(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 ( + +
+ + Loading… +
+
+ ); + } + if (identity && mustChangePassword) return ; + if (identity) return ; + + 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 ( + + + +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + aria-invalid={error ? true : undefined} + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + aria-invalid={error ? true : undefined} + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +}