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:
12 files changed
+688
-58
No files matched your search
+33
-19
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user