Repository Public
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