SPB Git

Repository Public

page.tsx 4.8 KB 88 lines tsx

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
Files main
Menu

Theme