mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
0a0198013d
* feat(auth): redesign login, MFA, and setup surfaces with cockpit voice Applies the cockpit design system to every auth surface: Login, MFA challenge, first-boot Setup, and the three MFA dialogs (Enroll, Backup Codes, Disable). Introduces shared primitives under components/auth/: AuthCanvas shell with bevelled card and cyan left rail, AuthStepHeader for tracked-mono kicker plus italic hero pair, OtpDigitField with six recessed digit cells and auto-submit, and ErrorRail for consistent inline errors. Preserves all behaviour: Local/LDAP toggle, dynamic SSO providers, auto-submit TOTP, backup-code fallback with dash formatting, rate-limit countdown, three-step enrollment, cold-start setup. Surface-only change; AuthContext, routing, and endpoints untouched. * test(e2e): update MFA spec selectors to match redesigned auth surfaces The auth redesign renamed buttons, restyled the backup-mode toggle to bracketed mono, changed input ids on the challenge + disable dialogs, and made the challenge TOTP path auto-submit (no explicit Verify button). Updates the spec accordingly: - Enroll step 1 button: Next -> Continue - Enroll step 3 button: "saved these" -> Done - Challenge heading: "Two-factor authentication" -> "Verify" - Backup toggle: "Use a backup code instead" -> "Use backup code" - Challenge verify button: "Verify and sign in" -> "Verify" - Challenge input id: #mfa-code -> #mfa-otp (TOTP) / #mfa-backup (backup) - Disable dialog backup input id: #mfa-disable-code -> #mfa-disable-backup All six MFA tests pass locally. No production code changed.
188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
import { useState } 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';
|
|
|
|
interface SetupProps {
|
|
onComplete: () => void;
|
|
}
|
|
|
|
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 strength = gaugePassword(password);
|
|
const strengthClass =
|
|
strength?.tone === 'strong'
|
|
? 'text-success'
|
|
: strength?.tone === 'fair'
|
|
? 'text-warning'
|
|
: strength
|
|
? 'text-destructive'
|
|
: '';
|
|
|
|
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) {
|
|
onComplete();
|
|
} else {
|
|
setError(data.error || 'Setup failed');
|
|
}
|
|
} catch {
|
|
setError('Network error. Please try again.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
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 Admiral account to unlock the console."
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|