SPB Git

Repository Public

page.tsx 4.7 KB 87 lines tsx

1'use client';2import { useEffect, useState } from 'react';3import { Check, X } from 'lucide-react';4import type { LoginStep } from '@/lib/dto-auth';5import { api, errorMessage, fetchAuthStatus } from '@/lib/api';6import { AuthCard } from '@/components/auth/auth-card';7import { SecondFactorEnroll } from '@/components/auth/second-factor';8import { Button } from '@/components/ui/button';9import { Field, Input } from '@/components/ui/input';10import { Spinner } from '@/components/ui/misc';11import { cn } from '@/lib/cn';12import { passwordChecks } from '@/lib/password';13import { BRAND as brand } from '@/lib/brand';1415export default function SetupPage() {16  const [step, setStep] = useState<'boot' | 'account' | 'enroll'>('boot');17  const [token, setToken] = useState('');18  const [username, setUsername] = useState('');19  const [password, setPassword] = useState('');20  const [confirm, setConfirm] = useState('');21  const [busy, setBusy] = useState(false);22  const [error, setError] = useState<string | null>(null);2324  useEffect(() => {25    fetchAuthStatus()26      .then((s) => {27        if (s.setupRequired) setStep('account');28        else if (s.authenticated) location.replace('/');29        else if (s.user && !s.methods.totp && s.methods.passkeys === 0) setStep('enroll'); // setup interrupted before enrolment30        else location.replace('/login/');31      })32      .catch(() => setStep('account'));33  }, []);3435  const checks = passwordChecks(password, username);36  const usernameOk = /^[a-z0-9_.-]{3,32}$/i.test(username);37  const valid = token.trim().length >= 16 && usernameOk && checks.every((c) => !c.required || c.ok) && password === confirm;38  const submit = async () => {39    setBusy(true);40    setError(null);41    try {42      await api.post<LoginStep>('/auth/setup', { setupToken: token.trim(), username: username.trim(), password });43      await fetchAuthStatus(); // picks up the CSRF token of the new session44      setStep('enroll');45    } catch (e) {46      setError(errorMessage(e));47    } finally {48      setBusy(false);49    }50  };5152  if (step === 'boot') return <div className="app-viewport flex items-center justify-center"><Spinner /></div>;53  if (step === 'enroll')54    return (55      <AuthCard step="enroll" title="Add a second factor" subtitle="Step 2 of 2 — mandatory. The account cannot be used with a password alone.">56        <SecondFactorEnroll onDone={() => location.assign('/')} />57      </AuthCard>58    );59  return (60    <AuthCard step="account" title={`Set up ${brand.product}`} subtitle="Step 1 of 2 — create the administrator account. This can be done only once.">61      <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); if (valid) void submit(); }}>62        <Field label="Setup token" hint="Printed once on the server, in the file “setup-token” of the data directory.">63          <Input value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-12 rounded-[14px] px-4 font-mono text-base" data-testid="setup-token" />64        </Field>65        <Field label="Username" hint="3–32 characters: letters, digits, “.”, “_”, “-”." error={username && !usernameOk ? 'Invalid username.' : null}>66          <Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-12 rounded-[14px] px-4 text-base" data-testid="setup-username" />67        </Field>68        <Field label="Password">69          <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" className="h-12 rounded-[14px] px-4 text-base" data-testid="setup-password" />70        </Field>71        <ul className="-mt-2 flex flex-col gap-1" aria-label="Password requirements">72          {checks.map((c) => (73            <li key={c.label} className={cn('flex items-center gap-1.5 text-[11px]', c.ok ? 'text-success' : c.required ? 'text-text-2' : 'text-muted')}>74              {c.ok ? <Check className="size-3" aria-label="met" /> : <X className="size-3" aria-label="not met" />} {c.label}75            </li>76          ))}77        </ul>78        <Field label="Confirm password" error={confirm && confirm !== password ? 'Passwords do not match.' : null}>79          <Input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" className="h-12 rounded-[14px] px-4 text-base" data-testid="setup-confirm" />80        </Field>81        {error && <p role="alert" className="text-xs text-error" data-testid="setup-error">{error}</p>}82        <Button type="submit" variant="brand" size="lg" className="rounded-full" loading={busy} disabled={!valid} data-testid="setup-submit">Create administrator</Button>83      </form>84    </AuthCard>85  );86}87
Files main
Menu

Theme