SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.8 KB · 141 lines tsx
Raw Blame History
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