TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { KeyRound, Lock, ShieldAlert, ShieldCheck } from "lucide-react";5import { Button, Input, Spinner } from "@/components/ui";6import { SpinzaMark } from "@/components/brand/logo";7import { api, ApiClientError } from "@/lib/api";8import { useAdmin } from "./store";9import type { AdminIdentity } from "./types";10import { ErrorState } from "./primitives";1112/** Gate: GET /api/admin/me → login form (401), restricted notice (403), or the console. */13export function AdminGate({ children }: { children: React.ReactNode }) {14 const status = useAdmin((s) => s.status);15 const error = useAdmin((s) => s.error);16 const check = useAdmin((s) => s.check);1718 React.useEffect(() => {19 void check();20 }, [check]);2122 if (status === "loading") {23 return (24 <div className="grid min-h-dvh place-items-center bg-bg text-fg-3">25 <div className="flex items-center gap-3 text-sm">26 <Spinner className="h-4 w-4" /> Checking admin session…27 </div>28 </div>29 );30 }31 if (status === "forbidden") return <Restricted message={error} />;32 if (status === "error") {33 return (34 <Frame>35 <ErrorState title="Admin API unreachable" error={error} onRetry={() => void check()} />36 </Frame>37 );38 }39 if (status === "unauthed") return <LoginScreen />;40 return <>{children}</>;41}4243function Frame({ children }: { children: React.ReactNode }) {44 return (45 <div className="grid min-h-dvh place-items-center bg-bg px-4 py-10">46 <div className="w-full max-w-sm">47 <div className="mb-6 flex items-center justify-center gap-2 text-fg">48 <SpinzaMark className="h-7 w-7" />49 <span className="text-lg font-semibold tracking-tight">50 SPIN<span className="text-accent">ZA</span> <span className="ml-1 text-fg-3">Admin</span>51 </span>52 </div>53 {children}54 </div>55 </div>56 );57}5859function Restricted({ message }: { message: string | null }) {60 return (61 <Frame>62 <div className="surface rounded-lg px-6 py-10 text-center">63 <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-danger/10 text-danger">64 <ShieldAlert className="h-6 w-6" />65 </div>66 <h1 className="text-lg font-semibold tracking-tight">Admin access is restricted</h1>67 <p className="mt-2 text-[13px] text-fg-3">{message ?? "This console is only reachable from allow-listed addresses."}</p>68 </div>69 </Frame>70 );71}7273function LoginScreen() {74 const signedIn = useAdmin((s) => s.signedIn);75 const [username, setUsername] = React.useState("");76 const [password, setPassword] = React.useState("");77 const [totp, setTotp] = React.useState("");78 const [busy, setBusy] = React.useState(false);79 const [error, setError] = React.useState<string | null>(null);80 const [forbidden, setForbidden] = React.useState<string | null>(null);8182 const totpOk = /^\d{6}$/.test(totp);83 const canSubmit = username.trim().length > 0 && password.length > 0 && totpOk && !busy;8485 async function submit(e: React.FormEvent) {86 e.preventDefault();87 if (!canSubmit) return;88 setBusy(true);89 setError(null);90 try {91 const r = await api<{ admin: AdminIdentity }>("/api/admin/auth/login", { json: { username: username.trim(), password, totp } });92 signedIn(r.admin);93 } catch (err) {94 if (err instanceof ApiClientError) {95 if (err.status === 403) setForbidden(err.message);96 else if (err.status === 429) setError("Too many attempts. Wait a few minutes and try again.");97 else if (err.status === 401) setError("Invalid credentials.");98 else setError(err.message);99 } else setError("Unexpected error.");100 setPassword("");101 setTotp("");102 } finally {103 setBusy(false);104 }105 }106107 if (forbidden) return <Restricted message={forbidden} />;108109 return (110 <Frame>111 <form onSubmit={submit} className="surface rounded-lg p-6" noValidate>112 <div className="mb-5 flex items-center gap-2">113 <div className="grid h-8 w-8 place-items-center rounded-md bg-accent-soft text-accent-2">114 <Lock className="h-4 w-4" />115 </div>116 <div>117 <h1 className="text-[15px] font-semibold tracking-tight">Admin sign-in</h1>118 <p className="text-[12px] text-fg-3">Username, password and a 6-digit authenticator code.</p>119 </div>120 </div>121 <div className="space-y-3">122 <Input label="Username" name="username" autoComplete="username" autoCapitalize="none" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />123 <Input label="Password" name="password" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />124 <Input label="Authenticator code" name="totp" inputMode="numeric" pattern="[0-9]*" maxLength={6} autoComplete="one-time-code" placeholder="000000" className="font-mono tracking-[0.3em]" value={totp} onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))} error={totp.length > 0 && !totpOk ? "Enter the 6 digits from your authenticator app." : null} />125 </div>126 {error ? (127 <div role="alert" className="mt-4 flex items-start gap-2 rounded-sm border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger">128 <ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}129 </div>130 ) : null}131 <Button type="submit" variant="accent" className="mt-5 w-full" loading={busy} disabled={!canSubmit}>132 <KeyRound className="h-4 w-4" /> Sign in133 </Button>134 <p className="mt-4 flex items-center justify-center gap-1.5 text-[11px] text-fg-4">135 <ShieldCheck className="h-3 w-3" /> Sessions expire after 12 hours. All actions are logged.136 </p>137 </form>138 </Frame>139 );140}141