Files
sencho/frontend/src/components/Setup.tsx
T
Anso 9b3f5c5a90 docs: relicense Sencho to AGPLv3 and reframe Community positioning (#1623)
* docs: relicense Sencho to AGPLv3 and reframe Community positioning

Replace BSL with AGPLv3 for the public Community product, update contributor
and licensing copy for Community-focused contributions, and surface source
and license links in Settings About.

* docs: clarify CLA scope and Community contribution framing

* fix(ui): split About link constants and harden AboutSection test selectors
2026-07-13 03:49:29 -04:00

264 lines
8.7 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ArrowRight, Loader2 } from 'lucide-react';
import { AuthCanvas } from '@/components/auth/AuthCanvas';
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
import { ErrorRail } from '@/components/auth/ErrorRail';
import { EnvironmentChecks, type EnvironmentReport } from '@/components/settings/EnvironmentChecks';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
interface SetupProps {
onComplete: () => void;
}
const POST_SETUP_KEY = 'sencho:post-setup';
const INPUT_CLASS =
'h-11 bg-background/60 border-card-border font-sans text-base shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] placeholder:text-stat-subtitle/60 focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-offset-0';
type Strength = { label: string; tone: 'weak' | 'fair' | 'strong' } | null;
function gaugePassword(pw: string): Strength {
if (pw.length === 0) return null;
if (pw.length < 8) return { label: 'Weak', tone: 'weak' };
const classes =
Number(/[a-z]/.test(pw)) +
Number(/[A-Z]/.test(pw)) +
Number(/\d/.test(pw)) +
Number(/[^A-Za-z0-9]/.test(pw));
if (pw.length >= 12 && classes >= 3) return { label: 'Strong', tone: 'strong' };
return { label: 'Fair', tone: 'fair' };
}
export function Setup({ onComplete, className, ...props }: SetupProps & React.ComponentPropsWithoutRef<'div'>) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'account' | 'env'>('account');
const [envReport, setEnvReport] = useState<EnvironmentReport | null>(null);
const [envLoading, setEnvLoading] = useState(false);
const loadEnvironment = useCallback(async () => {
setEnvLoading(true);
try {
const res = await apiFetch('/diagnostics/environment', { localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error((err as { error?: string })?.error || 'Failed to run environment checks.');
setEnvReport(null);
return;
}
setEnvReport((await res.json()) as EnvironmentReport);
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to run environment checks.');
setEnvReport(null);
} finally {
setEnvLoading(false);
}
}, []);
useEffect(() => {
if (step !== 'env') return;
void loadEnvironment();
}, [step, loadEnvironment]);
const strength = gaugePassword(password);
const strengthClass =
strength?.tone === 'strong'
? 'text-success'
: strength?.tone === 'fair'
? 'text-warning'
: strength
? 'text-destructive'
: '';
const handleEnterSencho = () => {
const adoptCount = envReport?.discovery?.adoptCandidateCount ?? 0;
if (adoptCount > 0) {
try {
sessionStorage.setItem(POST_SETUP_KEY, JSON.stringify({ openAdopt: true }));
} catch {
// sessionStorage unavailable
}
}
onComplete();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (username.length < 3) {
setError('Username must be at least 3 characters');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password, confirmPassword }),
});
const data = await response.json();
if (response.ok && data.success) {
setStep('env');
} else {
setError(data.error || 'Setup failed');
}
} catch {
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
if (step === 'env') {
return (
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex flex-col gap-4">
<Button
type="button"
onClick={handleEnterSencho}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
Enter Sencho<ArrowRight strokeWidth={1.5} />
</Button>
<div className="flex items-center justify-between border-t border-card-border/60 pt-3">
<span>Console · First boot</span>
<span className="text-stat-subtitle/70">Account ready</span>
</div>
</div>
}
>
<div className="flex flex-col gap-7">
<AuthStepHeader
kicker="SENCHO · ENVIRONMENT"
hero="Preflight"
caption="A quick check that this host can run Docker deploys. Warnings won't stop you; each one carries a fix."
/>
<EnvironmentChecks report={envReport} isLoading={envLoading} onRerun={loadEnvironment} />
</div>
</AuthCanvas>
</div>
);
}
return (
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex items-center justify-between">
<span>Console · First boot</span>
<span className="text-stat-subtitle/70">Empty database</span>
</div>
}
>
<div className="flex flex-col gap-7">
<AuthStepHeader
kicker="SENCHO · INITIALIZE"
hero="Cold start"
caption="Create the initial administrator account for this control plane."
/>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<Field id="username" label="Username">
<Input
id="username"
type="text"
placeholder="admin"
required
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={INPUT_CLASS}
/>
</Field>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
Password
</label>
{strength && (
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', strengthClass)}>
{strength.label}
</span>
)}
</div>
<Input
id="password"
type="password"
required
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<Field id="confirmPassword" label="Confirm password">
<Input
id="confirmPassword"
type="password"
required
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={INPUT_CLASS}
/>
</Field>
{error && <ErrorRail>{error}</ErrorRail>}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
{isLoading ? (
<><Loader2 className="animate-spin" strokeWidth={1.5} />Initializing</>
) : (
<>Initialize console<ArrowRight strokeWidth={1.5} /></>
)}
</Button>
</form>
</div>
</AuthCanvas>
</div>
);
}
function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={id}
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
{label}
</label>
{children}
</div>
);
}