Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { Check, X } from 'lucide-react';4import type { LoginStep } from '@cr/shared';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 { useBrand } from '@/stores/branding';1415export default function SetupPage() {16 const brand = useBrand();17 const [step, setStep] = useState<'boot' | 'account' | 'enroll'>('boot');18 const [token, setToken] = useState('');19 const [username, setUsername] = useState('');20 const [password, setPassword] = useState('');21 const [confirm, setConfirm] = useState('');22 const [busy, setBusy] = useState(false);23 const [error, setError] = useState<string | null>(null);2425 useEffect(() => {26 fetchAuthStatus()27 .then((s) => {28 if (s.setupRequired) setStep('account');29 else if (s.authenticated) location.replace('/');30 else if (s.user && !s.methods.totp && s.methods.passkeys === 0) setStep('enroll'); // setup interrupted before enrolment31 else location.replace('/login/');32 })33 .catch(() => setStep('account'));34 }, []);3536 const checks = passwordChecks(password, username);37 const usernameOk = /^[a-z0-9_.-]{3,32}$/i.test(username);38 const valid = token.trim().length >= 16 && usernameOk && checks.every((c) => !c.required || c.ok) && password === confirm;39 const submit = async () => {40 setBusy(true);41 setError(null);42 try {43 await api.post<LoginStep>('/auth/setup', { setupToken: token.trim(), username: username.trim(), password });44 await fetchAuthStatus(); // picks up the CSRF token of the new session45 setStep('enroll');46 } catch (e) {47 setError(errorMessage(e));48 } finally {49 setBusy(false);50 }51 };5253 if (step === 'boot') return <div className="app-viewport flex items-center justify-center"><Spinner /></div>;54 if (step === 'enroll')55 return (56 <AuthCard step="enroll" title="Add a second factor" subtitle="Step 2 of 2 — mandatory. The account cannot be used with a password alone.">57 <SecondFactorEnroll onDone={() => location.assign('/')} />58 </AuthCard>59 );60 return (61 <AuthCard step="account" title={`Set up ${brand.product}`} subtitle="Step 1 of 2 — create the administrator account. This can be done only once.">62 <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); if (valid) void submit(); }}>63 <Field label="Setup token" hint="Printed once on the server, in the file “setup-token” of the data directory.">64 <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" />65 </Field>66 <Field label="Username" hint="3–32 characters: letters, digits, “.”, “_”, “-”." error={username && !usernameOk ? 'Invalid username.' : null}>67 <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" />68 </Field>69 <Field label="Password">70 <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" />71 </Field>72 <ul className="-mt-2 flex flex-col gap-1" aria-label="Password requirements">73 {checks.map((c) => (74 <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')}>75 {c.ok ? <Check className="size-3" aria-label="met" /> : <X className="size-3" aria-label="not met" />} {c.label}76 </li>77 ))}78 </ul>79 <Field label="Confirm password" error={confirm && confirm !== password ? 'Passwords do not match.' : null}>80 <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" />81 </Field>82 {error && <p role="alert" className="text-xs text-error" data-testid="setup-error">{error}</p>}83 <Button type="submit" variant="brand" size="lg" className="rounded-full" loading={busy} disabled={!valid} data-testid="setup-submit">Create administrator</Button>84 </form>85 </AuthCard>86 );87}88