feat(auth): redesign login, MFA, and setup surfaces with cockpit voice (#714)

* 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.
This commit is contained in:
Anso
2026-04-20 17:58:57 -04:00
committed by GitHub
parent d95e154aeb
commit 0a0198013d
11 changed files with 1076 additions and 623 deletions
+30 -30
View File
@@ -85,8 +85,8 @@ test.describe.serial('Two-factor authentication', () => {
secret = startBody.secret;
expect(secret).toMatch(/^[A-Z2-7]+$/); // base32 alphabet
// Step 1 (QR) -> Next
await page.getByRole('button', { name: /^Next$/ }).click();
// Step 1 (QR) -> Continue
await page.getByRole('button', { name: /^Continue$/ }).click();
// Step 2 (Confirm): enter a fresh TOTP. The confirm step auto-submits on
// the sixth digit, so no explicit click is required. Capture the backup
@@ -101,7 +101,7 @@ test.describe.serial('Two-factor authentication', () => {
expect(backupCodes.length).toBe(10);
// Step 3 (Backup codes) -> acknowledge.
await page.getByRole('button', { name: /saved these/i }).click();
await page.getByRole('button', { name: /^Done$/ }).click();
// Card now shows the Enabled badge.
await expect(page.getByText(/^Enabled$/)).toBeVisible();
@@ -125,10 +125,10 @@ test.describe.serial('Two-factor authentication', () => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(backupCodes[5]);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(backupCodes[5]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
await openAccountSettings(page);
@@ -161,11 +161,11 @@ test.describe.serial('Two-factor authentication', () => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
// fill() emits the final value in a single onChange, which at length === 6
// schedules a submit via requestAnimationFrame. No explicit click.
await page.locator('#mfa-code').fill(totpNow(secret));
await page.locator('#mfa-otp').fill(totpNow(secret));
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
@@ -180,10 +180,10 @@ test.describe.serial('Two-factor authentication', () => {
expect(raw).toMatch(/^[A-Z0-9]{10}$/);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(raw);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(raw);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
@@ -196,19 +196,19 @@ test.describe.serial('Two-factor authentication', () => {
// First use: should succeed.
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(code);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(code);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
// Log out and try the same backup code again: should fail.
await logout(page);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(code);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(code);
await page.getByRole('button', { name: /^Verify$/ }).click();
// Error should be visible and we should still be on the challenge screen.
await expect(page.locator('.text-destructive')).toBeVisible();
@@ -218,9 +218,9 @@ test.describe.serial('Two-factor authentication', () => {
// 30-second window against the one test #2 consumed, which the server
// (correctly) rejects as a replay when the boundary falls the wrong
// way. Backup codes are single-use and sidestep that blacklist.
await page.locator('#mfa-code').clear();
await page.locator('#mfa-code').fill(backupCodes[1]);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await page.locator('#mfa-backup').clear();
await page.locator('#mfa-backup').fill(backupCodes[1]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
@@ -232,16 +232,16 @@ test.describe.serial('Two-factor authentication', () => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(backupCodes[2]);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(backupCodes[2]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
await openAccountSettings(page);
await page.getByRole('button', { name: /Disable 2FA/i }).click();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-disable-code').fill(backupCodes[3]);
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-disable-backup').fill(backupCodes[3]);
await page.getByRole('button', { name: /^Disable$/ }).click();
// Card flips back to the "Set up 2FA" call to action.
+160 -130
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyRound } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ArrowRight, KeyRound, Loader2 } from 'lucide-react';
import { AuthCanvas } from '@/components/auth/AuthCanvas';
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface SSOProvider {
provider: string;
@@ -12,11 +14,14 @@ interface SSOProvider {
type: 'ldap' | 'oidc';
}
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';
function getProviderIcon(provider: string) {
switch (provider) {
case 'oidc_google':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
@@ -25,27 +30,23 @@ function getProviderIcon(provider: string) {
);
case 'oidc_github':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
case 'oidc_okta':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 0C5.389 0 0 5.389 0 12s5.389 12 12 12 12-5.389 12-12S18.611 0 12 0zm0 18c-3.314 0-6-2.686-6-6s2.686-6 6-6 6 2.686 6 6-2.686 6-6 6z" />
</svg>
);
case 'oidc_custom':
return <KeyRound className="w-4 h-4 mr-2" />;
default:
return null;
return <KeyRound className="h-4 w-4" strokeWidth={1.5} aria-hidden />;
}
}
export function Login({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) {
export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const { login, ssoLdapLogin } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -61,160 +62,189 @@ export function Login({
const [isLoading, setIsLoading] = useState(false);
const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local');
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [capsLock, setCapsLock] = useState(false);
useEffect(() => {
fetch('/api/auth/sso/providers', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then((r) => (r.ok ? r.json() : []))
.then((providers: SSOProvider[]) => setSsoProviders(providers))
.catch((e) => {
console.warn('[Login] SSO provider discovery failed:', e);
});
}, []);
const hasLdap = ssoProviders.some(p => p.type === 'ldap');
const oidcProviders = ssoProviders.filter(p => p.type === 'oidc');
const hasLdap = ssoProviders.some((p) => p.type === 'ldap');
const oidcProviders = ssoProviders.filter((p) => p.type === 'oidc');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = loginMode === 'ldap' && ssoLdapLogin
? await ssoLdapLogin(username, password)
: await login(username, password);
if (!result.success) {
setError(result.error || 'Login failed');
}
const result =
loginMode === 'ldap' && ssoLdapLogin
? await ssoLdapLogin(username, password)
: await login(username, password);
if (!result.success) setError(result.error || 'Login failed');
setIsLoading(false);
};
const handlePasswordKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (typeof e.getModifierState === 'function') {
setCapsLock(e.getModifierState('CapsLock'));
}
};
return (
<div className={cn("grid min-h-svh md:grid-cols-2", className)} {...props}>
{/* ── Left: Branding Panel (desktop only) ── */}
<div className="relative hidden md:flex flex-col items-center justify-center bg-zinc-950 overflow-hidden">
{/* Dot grid texture */}
<div
className="absolute inset-0 opacity-[0.15]"
style={{
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.7) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
{/* Branding content */}
<div className="relative z-10 flex flex-col items-center gap-6 px-12">
<img
src="/sencho-logo-dark.png"
alt="Sencho"
className="w-28 h-28"
draggable={false}
/>
<div className="text-center">
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex items-center justify-between">
<span>Console · Local</span>
<span className="text-stat-subtitle/70">Secure by default</span>
</div>
</div>
{/* Brand accent line */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-brand" />
</div>
{/* ── Right: Form Panel ── */}
<div className="flex flex-col items-center justify-center bg-background px-6 py-12">
{/* Mobile logo header */}
<div className="flex items-center gap-2.5 mb-10 md:hidden">
<img src="/sencho-logo-light.png" alt="Sencho" className="w-8 h-8 dark:hidden" draggable={false} />
<img src="/sencho-logo-dark.png" alt="Sencho" className="w-8 h-8 hidden dark:block" draggable={false} />
<span className="text-lg font-semibold tracking-tight">Sencho</span>
</div>
<div className="w-full max-w-sm">
<div className="mb-8">
<h2 className="text-2xl font-bold tracking-tight">Welcome back</h2>
<p className="text-sm text-muted-foreground mt-1">
Sign in to your Sencho instance
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-5">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="admin"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
}
>
<div className="flex flex-col gap-7">
<div className="flex items-start justify-between gap-4">
<AuthStepHeader
kicker="SENCHO · AUTHENTICATE"
hero="Sign in"
caption={
loginMode === 'ldap'
? 'Federated via your directory service.'
: 'Self-hosted fleet console.'
}
/>
{hasLdap && (
<div className="mt-1 flex overflow-hidden rounded-md border border-card-border">
<ModePill
active={loginMode === 'local'}
label="Local"
onClick={() => setLoginMode('local')}
/>
<ModePill
active={loginMode === 'ldap'}
label="LDAP"
onClick={() => setLoginMode('ldap')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="text-sm text-destructive text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading
? 'Logging in...'
: loginMode === 'ldap'
? 'Sign in with LDAP'
: 'Login'
}
</Button>
{hasLdap && (
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground text-center transition-colors"
onClick={() => setLoginMode(loginMode === 'local' ? 'ldap' : 'local')}
>
{loginMode === 'local' ? 'Sign in with LDAP instead' : 'Sign in with password instead'}
</button>
)}
)}
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
Username
</label>
<Input
id="username"
type="text"
placeholder="admin"
required
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<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>
{capsLock && (
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
Caps Lock On
</span>
)}
</div>
<Input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handlePasswordKey}
onKeyUp={handlePasswordKey}
className={INPUT_CLASS}
/>
</div>
{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} />
Signing in
</>
) : (
<>
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
<ArrowRight strokeWidth={1.5} />
</>
)}
</Button>
</form>
{oidcProviders.length > 0 && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-card-border" />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Or continue with
</span>
<div className="h-px flex-1 bg-card-border" />
</div>
<div className="flex flex-col gap-2">
{oidcProviders.map(p => (
<div className="grid grid-cols-2 gap-2">
{oidcProviders.map((p) => (
<Button
key={p.provider}
type="button"
variant="outline"
className="w-full"
className="h-10 justify-center gap-2 font-sans"
onClick={() => {
window.location.href = `/api/auth/sso/oidc/${p.provider}/authorize`;
}}
>
{getProviderIcon(p.provider)}
{p.displayName}
<span className="truncate">{p.displayName}</span>
</Button>
))}
</div>
</>
</div>
)}
</div>
</div>
</AuthCanvas>
</div>
);
}
function ModePill({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'px-3 py-1 font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
active ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-stat-value',
)}
>
{label}
</button>
);
}
+163 -103
View File
@@ -1,174 +1,234 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2 } from 'lucide-react';
import {
BACKUP_CODE_DISPLAY_LENGTH,
TOTP_LENGTH,
normalizeBackupCodeInput,
normalizeTotpInput,
} from '@/lib/mfa';
import { AuthCanvas } from '@/components/auth/AuthCanvas';
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
export function MfaChallenge({
className,
...props
}: React.ComponentPropsWithoutRef<'div'>) {
type FieldState = 'idle' | 'loading' | 'error' | 'success';
function formatSeconds(total: number): string {
const mm = Math.floor(total / 60).toString().padStart(2, '0');
const ss = Math.floor(total % 60).toString().padStart(2, '0');
return `${mm}:${ss}`;
}
export function MfaChallenge({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const { submitMfa, cancelMfa } = useAuth();
// `display` is what the user sees in the input (with dash for backup codes);
// `raw` is the normalized value we send to the server.
const [display, setDisplay] = useState('');
const [raw, setRaw] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [fieldState, setFieldState] = useState<FieldState>('idle');
const [useBackup, setUseBackup] = useState(false);
// Latch so auto-submit only fires once per full code entry: cleared on any
// edit that brings the input back below a full code.
const [retrySeconds, setRetrySeconds] = useState(0);
const submittedRef = useRef(false);
useEffect(() => {
if (retrySeconds <= 0) return;
const id = window.setInterval(() => {
setRetrySeconds((s) => (s <= 1 ? 0 : s - 1));
}, 1000);
return () => window.clearInterval(id);
}, [retrySeconds]);
const runSubmit = async (valueToSubmit: string) => {
setError('');
setIsLoading(true);
setFieldState('loading');
const result = await submitMfa(valueToSubmit, { isBackupCode: useBackup });
if (!result.success) {
const retryNote = result.retryAfter ? ` (try again in ${Math.ceil(result.retryAfter / 60)} min)` : '';
setError((result.error || 'Verification failed') + retryNote);
if (result.retryAfter && result.retryAfter > 0) {
setRetrySeconds(Math.ceil(result.retryAfter));
setError('');
} else {
setError(result.error || 'Verification failed');
}
setDisplay('');
setRaw('');
setFieldState('error');
submittedRef.current = false;
window.setTimeout(() => setFieldState('idle'), 600);
return;
}
setIsLoading(false);
setFieldState('success');
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isLoading || !raw) return;
if (fieldState === 'loading' || !raw) return;
submittedRef.current = true;
void runSubmit(raw);
};
const handleChange = (value: string) => {
if (useBackup) {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
if (next.raw.length < 10) submittedRef.current = false;
// Backup codes are longer and deliberate; do not auto-submit.
return;
}
const handleOtpChange = (value: string) => {
const normalized = normalizeTotpInput(value);
setDisplay(normalized);
setRaw(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (fieldState === 'error') setFieldState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!isLoading &&
fieldState !== 'loading' &&
!submittedRef.current
) {
submittedRef.current = true;
// Let the state update flush before firing so the spinner state lines
// up with the disabled button.
requestAnimationFrame(() => { void runSubmit(normalized); });
}
};
const handleBackupChange = (value: string) => {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
if (next.raw.length < 10) submittedRef.current = false;
if (fieldState === 'error') setFieldState('idle');
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setDisplay('');
setRaw('');
setError('');
setFieldState('idle');
submittedRef.current = false;
};
const throttled = retrySeconds > 0;
return (
<div className={cn('grid min-h-svh md:grid-cols-2', className)} {...props}>
{/* Branding panel (matches Login layout) */}
<div className="relative hidden md:flex flex-col items-center justify-center bg-zinc-950 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.15]"
style={{
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.7) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
<div className="relative z-10 flex flex-col items-center gap-6 px-12">
<img
src="/sencho-logo-dark.png"
alt="Sencho"
className="w-28 h-28"
draggable={false}
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex items-center justify-between">
<span>Console · Verify</span>
<button
type="button"
onClick={cancelMfa}
className="uppercase tracking-[0.18em] text-stat-subtitle/80 transition-colors hover:text-destructive"
>
Cancel · Sign out
</button>
</div>
}
>
<div className="flex flex-col gap-7">
<AuthStepHeader
kicker={throttled ? 'SENCHO · THROTTLED' : 'SENCHO · VERIFY'}
hero={throttled ? formatSeconds(retrySeconds) : 'Verify'}
caption={
throttled
? 'Too many attempts. Take a breath and try again shortly.'
: useBackup
? 'Enter one of your saved backup codes to continue.'
: 'Enter the 6-digit code from your authenticator.'
}
/>
<div className="text-center">
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-px bg-brand" />
</div>
{/* Form panel */}
<div className="flex flex-col items-center justify-center bg-background px-6 py-12">
<div className="flex items-center gap-2.5 mb-10 md:hidden">
<img src="/sencho-logo-light.png" alt="Sencho" className="w-8 h-8 dark:hidden" draggable={false} />
<img src="/sencho-logo-dark.png" alt="Sencho" className="w-8 h-8 hidden dark:block" draggable={false} />
<span className="text-lg font-semibold tracking-tight">Sencho</span>
</div>
<div className="w-full max-w-sm">
<div className="mb-8">
<h2 className="text-2xl font-bold tracking-tight">Two-factor authentication</h2>
<p className="text-sm text-muted-foreground mt-1">
{useBackup
? 'Enter one of your saved backup codes to continue.'
: 'Open your authenticator app and enter the 6-digit code.'}
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-5">
<div className="grid gap-2">
<Label htmlFor="mfa-code">{useBackup ? 'Backup code' : 'Verification code'}</Label>
{throttled ? (
<ThrottleTile seconds={retrySeconds} />
) : useBackup ? (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Backup code · 10 chars
</span>
<Input
id="mfa-code"
id="mfa-backup"
type="text"
inputMode={useBackup ? 'text' : 'numeric'}
inputMode="text"
autoComplete="one-time-code"
autoFocus
required
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
maxLength={BACKUP_CODE_DISPLAY_LENGTH}
value={display}
onChange={(e) => handleChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
onChange={(e) => handleBackupChange(e.target.value)}
placeholder="ABCDE-FGHIJ"
className="h-12 bg-background/60 border-card-border text-center font-mono text-lg tabular-nums tracking-[0.3em] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40"
/>
</div>
{error && (
<div className="text-sm text-destructive text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !raw}>
{isLoading ? 'Verifying...' : 'Verify and sign in'}
{error && <ErrorRail>{error}</ErrorRail>}
<Button
type="submit"
disabled={fieldState === 'loading' || raw.length !== 10}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
{fieldState === 'loading' ? (
<><Loader2 className="animate-spin" strokeWidth={1.5} />Verifying</>
) : (
'Verify'
)}
</Button>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground text-center transition-colors"
onClick={handleToggleBackup}
>
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
</button>
<button
type="button"
className="text-sm text-muted-foreground/70 hover:text-foreground text-center transition-colors"
onClick={cancelMfa}
>
Cancel and sign out
</button>
</form>
) : (
<div className="flex flex-col gap-5">
<OtpDigitField
id="mfa-otp"
value={display}
onChange={handleOtpChange}
state={fieldState}
disabled={fieldState === 'loading' || fieldState === 'success'}
autoFocus
/>
{error && <ErrorRail>{error}</ErrorRail>}
</div>
</form>
)}
<ModeToggle
useBackup={useBackup}
onToggle={handleToggleBackup}
disabled={fieldState === 'loading' || throttled}
/>
</div>
</AuthCanvas>
</div>
);
}
function ThrottleTile({ seconds }: { seconds: number }) {
return (
<div className="relative overflow-hidden rounded-md border border-warning/30 bg-warning/6 pl-4 pr-3 py-3">
<span className="absolute inset-y-0 left-0 w-[3px] bg-warning/70" aria-hidden />
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">Retry in</span>
<span className="font-mono text-2xl tabular-nums text-stat-value">{formatSeconds(seconds)}</span>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Rate limited
</span>
</div>
</div>
);
}
function ModeToggle({
useBackup,
onToggle,
disabled,
}: {
useBackup: boolean;
onToggle: () => void;
disabled: boolean;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
className="self-start font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle transition-colors hover:text-brand disabled:opacity-50"
>
{useBackup ? '[ Use authenticator ]' : '[ Use backup code ]'}
</button>
);
}
+128 -108
View File
@@ -1,62 +1,76 @@
import { useState } from 'react';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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;
}
export function Setup({
onComplete,
className,
...props
}: SetupProps & React.ComponentPropsWithoutRef<"div">) {
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('');
// Client-side validation
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',
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username,
password,
confirmPassword,
}),
body: JSON.stringify({ username, password, confirmPassword }),
});
const data = await response.json();
if (response.ok && data.success) {
onComplete();
} else {
@@ -70,98 +84,104 @@ export function Setup({
};
return (
<div className={cn("grid min-h-svh md:grid-cols-2", className)} {...props}>
{/* ── Left: Branding Panel (desktop only) ── */}
<div className="relative hidden md:flex flex-col items-center justify-center bg-zinc-950 overflow-hidden">
{/* Dot grid texture */}
<div
className="absolute inset-0 opacity-[0.15]"
style={{
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.7) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
{/* Branding content */}
<div className="relative z-10 flex flex-col items-center gap-6 px-12">
<img
src="/sencho-logo-dark.png"
alt="Sencho"
className="w-28 h-28"
draggable={false}
<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."
/>
<div className="text-center">
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
</div>
</div>
{/* Brand accent line */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-brand" />
</div>
<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>
{/* ── Right: Form Panel ── */}
<div className="flex flex-col items-center justify-center bg-background px-6 py-12">
{/* Mobile logo header */}
<div className="flex items-center gap-2.5 mb-10 md:hidden">
<img src="/sencho-logo-light.png" alt="Sencho" className="w-8 h-8 dark:hidden" draggable={false} />
<img src="/sencho-logo-dark.png" alt="Sencho" className="w-8 h-8 hidden dark:block" draggable={false} />
<span className="text-lg font-semibold tracking-tight">Sencho</span>
</div>
<div className="w-full max-w-sm">
<div className="mb-8">
<h2 className="text-2xl font-bold tracking-tight">Create your account</h2>
<p className="text-sm text-muted-foreground mt-1">
Set up the admin credentials for your Sencho instance
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-5">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="admin"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<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>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && (
<div className="text-sm text-red-500 text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Setting up...' : 'Complete Setup'}
</Button>
<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>
</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>
);
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface AuthCanvasProps extends React.HTMLAttributes<HTMLDivElement> {
footer?: React.ReactNode;
}
export function AuthCanvas({ children, className, footer, ...props }: AuthCanvasProps) {
return (
<div
className={cn(
'relative flex min-h-svh flex-col items-center justify-center px-4 py-10 sm:px-6',
className,
)}
{...props}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 [background:radial-gradient(circle_at_50%_-10%,oklch(0.78_0.11_195_/_0.10),transparent_55%)]"
/>
<div
role="group"
className="relative w-full max-w-[440px] animate-scale-in overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel"
style={{ animationDuration: 'var(--duration-base)', animationTimingFunction: 'var(--ease-out-expo)' }}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
<div className="flex items-center justify-between border-b border-card-border/60 px-7 pt-6 pb-4">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
SENCHO
</span>
<span className="h-1.5 w-1.5 rounded-full bg-brand shadow-[0_0_8px_0_oklch(0.78_0.11_195_/_0.6)]" />
</div>
<div className="px-7 pb-7 pt-6">{children}</div>
{footer && (
<div className="border-t border-card-border/60 px-7 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{footer}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface AuthStepHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
kicker: string;
hero: string;
caption?: React.ReactNode;
}
export function AuthStepHeader({ kicker, hero, caption, className, ...props }: AuthStepHeaderProps) {
return (
<div className={cn('flex flex-col gap-1.5', className)} {...props}>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
<h1 className="font-display text-[2.25rem] italic leading-[1.05] text-stat-value">
{hero}
</h1>
{caption && (
<p className="text-sm leading-snug text-stat-subtitle">{caption}</p>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
export function ErrorRail({ children }: { children: ReactNode }) {
return (
<div className="relative overflow-hidden rounded-md border border-destructive/30 bg-destructive/8 pl-4 pr-3 py-2.5">
<span className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" aria-hidden />
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">Error</div>
<div className="text-sm leading-snug text-stat-value">{children}</div>
</div>
);
}
@@ -0,0 +1,93 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type OtpState = 'idle' | 'loading' | 'success' | 'error';
interface OtpDigitFieldProps {
value: string;
onChange: (value: string) => void;
length?: number;
state?: OtpState;
disabled?: boolean;
autoFocus?: boolean;
id?: string;
ariaLabel?: string;
}
export function OtpDigitField({
value,
onChange,
length = 6,
state = 'idle',
disabled = false,
autoFocus = false,
id,
ariaLabel = '6-digit verification code',
}: OtpDigitFieldProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (autoFocus && inputRef.current && !disabled) inputRef.current.focus();
}, [autoFocus, disabled]);
const activeIndex = Math.min(value.length, length - 1);
const isSuccess = state === 'success';
const isError = state === 'error';
const isLoading = state === 'loading';
return (
<div
role="group"
aria-label={ariaLabel}
className={cn(
'relative flex w-full items-stretch gap-2',
disabled && 'pointer-events-none opacity-60',
)}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef}
id={id}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={length}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute inset-0 h-full w-full cursor-text opacity-0"
/>
{Array.from({ length }).map((_, i) => {
const char = value[i] ?? '';
const filled = char !== '';
const isActive = focused && !disabled && i === activeIndex;
return (
<div
key={i}
aria-hidden
className={cn(
'relative flex h-14 flex-1 items-center justify-center rounded-md border bg-background transition-colors',
'shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]',
'font-mono tabular-nums text-2xl',
isError
? 'border-destructive/60 text-destructive'
: isSuccess
? 'border-brand/60 text-brand'
: isActive
? 'border-brand/70 text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4),0_0_0_3px_oklch(0.78_0.11_195_/_0.22)]'
: filled
? 'border-card-border-top text-stat-value'
: 'border-card-border text-stat-subtitle',
isLoading && 'opacity-70',
)}
>
{char || (isActive ? <span className="h-5 w-[1.5px] animate-pulse bg-brand" /> : null)}
</div>
);
})}
</div>
);
}
@@ -1,12 +1,20 @@
import { useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Download } from 'lucide-react';
import { Check, Copy, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface MfaBackupCodesDialogProps {
open: boolean;
@@ -21,6 +29,7 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [confirmState, setConfirmState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const submittedRef = useRef(false);
@@ -28,12 +37,14 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
setStep('confirm');
setCode('');
setError('');
setConfirmState('idle');
setBackupCodes([]);
submittedRef.current = false;
};
const submitRegenerate = async (valueToSubmit: string) => {
setError('');
setConfirmState('loading');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/backup-codes/regenerate', {
@@ -45,30 +56,30 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
if (!res.ok) {
setError(data?.error || 'Could not regenerate backup codes');
setCode('');
setConfirmState('error');
submittedRef.current = false;
window.setTimeout(() => setConfirmState('idle'), 600);
return;
}
setBackupCodes(data.backupCodes || []);
setConfirmState('success');
setStep('show');
} catch (err) {
setError((err as Error)?.message || 'Could not regenerate backup codes');
setConfirmState('error');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (loading || code.length !== TOTP_LENGTH) return;
submittedRef.current = true;
void submitRegenerate(code);
};
const handleCodeChange = (raw: string) => {
const normalized = normalizeTotpInput(raw);
setCode(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (confirmState === 'error') setConfirmState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!loading &&
@@ -120,74 +131,119 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
onOpenChange(next);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'confirm' ? 'Regenerate backup codes' : 'New backup codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Replace your backup codes with a freshly generated set. The previous
set stops working immediately.
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md overflow-hidden p-0">
<div className="relative">
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
{step === 'confirm' && (
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Your current backup codes will stop working immediately. Confirm with a code from your authenticator app to continue.
</p>
<div className="grid gap-2">
<Label htmlFor="mfa-regen-code">Verification code</Label>
<Input
id="mfa-regen-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
maxLength={TOTP_LENGTH}
value={code}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
<DialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
SENCHO · MFA
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
{loading ? 'Working...' : 'Regenerate'}
</Button>
</DialogFooter>
</form>
)}
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
{step === 'confirm' ? 'Confirm identity' : 'New recovery codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Replace your backup codes with a freshly generated set. The previous
set stops working immediately.
</DialogDescription>
</DialogHeader>
{step === 'show' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Each code can be used once. Store them somewhere safe; they will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 rounded-md border border-card-border bg-card p-4 font-mono text-sm tabular-nums tracking-wider shadow-card-bevel">
{backupCodes.map((c) => (
<div key={c} className="text-center">{c}</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter>
<Button type="button" onClick={handleFinish}>I&apos;ve saved these</Button>
</DialogFooter>
<div className="px-6 py-5">
{step === 'confirm' && (
<div className="flex flex-col gap-4">
<p className="text-sm leading-snug text-stat-subtitle">
Enter a code from your authenticator to generate a new set. The previous codes stop working immediately.
</p>
<OtpDigitField
id="mfa-regen-code"
value={code}
onChange={handleCodeChange}
state={confirmState}
disabled={loading || confirmState === 'success'}
autoFocus
/>
{error && <ErrorRail>{error}</ErrorRail>}
<DialogFooter className="mt-2 gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
</DialogFooter>
</div>
)}
{step === 'show' && (
<div className="flex flex-col gap-4">
<WarningRail>Previous codes have been invalidated.</WarningRail>
<p className="text-sm leading-snug text-stat-subtitle">
Each code can be used once. Store them safely. They will not be shown again.
</p>
<BackupCodeTicket codes={backupCodes} />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
<Copy className="h-4 w-4" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
<Download className="h-4 w-4" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
onClick={handleFinish}
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
<Check strokeWidth={1.5} />
Done
</Button>
</DialogFooter>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function BackupCodeTicket({ codes }: { codes: string[] }) {
return (
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Recovery codes</span>
<span className="tabular-nums">{codes.length} issued</span>
</div>
<ol className="grid grid-cols-1 sm:grid-cols-2">
{codes.map((c, i) => (
<li
key={c}
className={cn(
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
'border-t border-card-border/40',
i === 0 && 'sm:border-t-0',
i === 1 && 'sm:border-t-0',
)}
>
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{String(i + 1).padStart(2, '0')}
</span>
<span>{c}</span>
</li>
))}
</ol>
</div>
);
}
function WarningRail({ children }: { children: React.ReactNode }) {
return (
<div className="relative overflow-hidden rounded-md border border-warning/30 bg-warning/8 pl-4 pr-3 py-2">
<span className="absolute inset-y-0 left-0 w-[3px] bg-warning/70" aria-hidden />
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
{children}
</div>
</div>
);
}
@@ -10,7 +10,6 @@ import {
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import {
@@ -20,6 +19,8 @@ import {
normalizeBackupCodeInput,
normalizeTotpInput,
} from '@/lib/mfa';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface MfaDisableDialogProps {
open: boolean;
@@ -28,13 +29,12 @@ interface MfaDisableDialogProps {
}
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
// `display` is what the input shows (backup codes carry a dash after five chars);
// `raw` is the normalized value sent to the server.
const [display, setDisplay] = useState('');
const [raw, setRaw] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [otpState, setOtpState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
const submittedRef = useRef(false);
useEffect(() => {
@@ -43,12 +43,14 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
setRaw('');
setError('');
setUseBackup(false);
setOtpState('idle');
submittedRef.current = false;
}
}, [open]);
const submitDisable = async (valueToSubmit: string) => {
setError('');
setOtpState('loading');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/disable', {
@@ -61,16 +63,20 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
setError(data?.error || 'Could not disable two-factor authentication');
setDisplay('');
setRaw('');
setOtpState('error');
submittedRef.current = false;
window.setTimeout(() => setOtpState('idle'), 600);
return;
}
toast.success('Two-factor authentication disabled');
setOtpState('success');
setDisplay('');
setRaw('');
onOpenChange(false);
onDisabled();
} catch (err) {
setError((err as Error)?.message || 'Could not disable two-factor authentication');
setOtpState('error');
submittedRef.current = false;
} finally {
setLoading(false);
@@ -79,19 +85,14 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
const expectedLength = useBackup ? BACKUP_CODE_RAW_LENGTH : TOTP_LENGTH;
const handleCodeChange = (value: string) => {
if (useBackup) {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
// Never auto-submit a backup code; the action is destructive.
if (next.raw.length < BACKUP_CODE_RAW_LENGTH) submittedRef.current = false;
return;
}
const handleOtpChange = (value: string) => {
const normalized = normalizeTotpInput(value);
setDisplay(normalized);
setRaw(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (otpState === 'error') setOtpState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!loading &&
@@ -102,11 +103,20 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
}
};
const handleBackupChange = (value: string) => {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
if (next.raw.length < BACKUP_CODE_RAW_LENGTH) submittedRef.current = false;
if (otpState === 'error') setOtpState('idle');
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setDisplay('');
setRaw('');
setError('');
setOtpState('idle');
submittedRef.current = false;
};
@@ -118,51 +128,74 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disable two-factor authentication?</AlertDialogTitle>
<AlertDialogDescription>
Your account will only be protected by a password. Anyone who obtains that password can sign in. Confirm with a current code to continue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogContent className="max-w-md overflow-hidden p-0">
<div className="relative">
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" />
<div className="flex flex-col gap-3">
<div className="grid gap-2">
<Label htmlFor="mfa-disable-code">{useBackup ? 'Backup code' : 'Verification code'}</Label>
<Input
id="mfa-disable-code"
type="text"
inputMode={useBackup ? 'text' : 'numeric'}
autoComplete="one-time-code"
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
value={display}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
/>
<AlertDialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-destructive">
SENCHO · MFA · DISABLE
</div>
<AlertDialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
Turn off two-factor
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 text-sm leading-snug text-stat-subtitle">
Disabling 2FA removes this login layer. Your backup codes become invalid. Confirm with a current code to proceed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4 px-6 py-5">
{useBackup ? (
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Backup code · 10 chars
</span>
<Input
id="mfa-disable-backup"
type="text"
inputMode="text"
autoComplete="one-time-code"
maxLength={BACKUP_CODE_DISPLAY_LENGTH}
value={display}
onChange={(e) => handleBackupChange(e.target.value)}
placeholder="ABCDE-FGHIJ"
className="h-12 bg-background/60 border-card-border text-center font-mono text-lg tabular-nums tracking-[0.3em] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40"
/>
</div>
) : (
<OtpDigitField
id="mfa-disable-code"
value={display}
onChange={handleOtpChange}
state={otpState}
disabled={loading || otpState === 'success'}
autoFocus
/>
)}
<button
type="button"
onClick={handleToggleBackup}
className="self-start font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle transition-colors hover:text-brand"
>
{useBackup ? '[ Use authenticator ]' : '[ Use backup code ]'}
</button>
{error && <ErrorRail>{error}</ErrorRail>}
</div>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
onClick={handleToggleBackup}
>
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
</button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
type="button"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={loading || raw.length !== expectedLength}
onClick={handleDisableClick}
>
{loading ? 'Disabling...' : 'Disable'}
</Button>
</AlertDialogFooter>
<AlertDialogFooter className="border-t border-card-border/60 px-6 py-4">
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={loading || raw.length !== expectedLength}
onClick={handleDisableClick}
>
{loading ? 'Disabling...' : 'Disable'}
</Button>
</AlertDialogFooter>
</div>
</AlertDialogContent>
</AlertDialog>
);
+202 -122
View File
@@ -1,13 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Download, ChevronDown, ChevronRight } from 'lucide-react';
import { ArrowRight, Check, Copy, Download, Loader2 } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface MfaEnrollDialogProps {
open: boolean;
@@ -17,10 +25,6 @@ interface MfaEnrollDialogProps {
type Step = 'qr' | 'confirm' | 'backup';
/**
* Format a raw base32 secret as groups of 4 characters so it is easier for
* users typing it into authenticator apps manually.
*/
function formatSecret(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim();
}
@@ -30,14 +34,12 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
const [loading, setLoading] = useState(false);
const [otpauthUri, setOtpauthUri] = useState('');
const [secret, setSecret] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [confirmState, setConfirmState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
// Latch so auto-submit only fires once per complete entry.
const submittedRef = useRef(false);
// When the dialog opens, start enrolment so the QR is ready immediately.
useEffect(() => {
if (!open) return;
let cancelled = false;
@@ -45,7 +47,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
setCode('');
setError('');
setBackupCodes([]);
setShowSecret(false);
setConfirmState('idle');
submittedRef.current = false;
setLoading(true);
apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true })
@@ -71,6 +73,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
const submitConfirm = async (valueToSubmit: string) => {
setError('');
setConfirmState('loading');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/enroll/confirm', {
@@ -82,30 +85,30 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
if (!res.ok) {
setError(data?.error || 'Verification failed');
setCode('');
setConfirmState('error');
submittedRef.current = false;
window.setTimeout(() => setConfirmState('idle'), 600);
return;
}
setBackupCodes(data.backupCodes || []);
setConfirmState('success');
setStep('backup');
} catch (err) {
setError((err as Error)?.message || 'Verification failed');
setConfirmState('error');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (loading || code.length !== TOTP_LENGTH) return;
submittedRef.current = true;
void submitConfirm(code);
};
const handleCodeChange = (raw: string) => {
const normalized = normalizeTotpInput(raw);
setCode(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (confirmState === 'error') setConfirmState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!loading &&
@@ -158,120 +161,197 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
<Dialog
open={open}
onOpenChange={(next) => {
// Once backup codes have been shown, a close is equivalent to
// finishing, so the parent can refresh the status card.
if (!next && step === 'backup') onEnrolled();
onOpenChange(next);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'qr' && 'Set up two-factor authentication'}
{step === 'confirm' && 'Confirm your authenticator'}
{step === 'backup' && 'Save your backup codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Enrol a time-based one-time password (TOTP) authenticator and save
single-use backup codes.
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md overflow-hidden p-0">
<div className="relative">
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
{step === 'qr' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator.
</p>
<div className="flex justify-center rounded-md border border-card-border bg-card p-4 shadow-card-bevel">
{otpauthUri
? <QRCodeSVG value={otpauthUri} size={176} />
: <div className="h-[176px] w-[176px] bg-muted/20 animate-pulse rounded" />
}
<DialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
SENCHO · MFA
</div>
<button
type="button"
onClick={() => setShowSecret((v) => !v)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showSecret ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
Can&apos;t scan? Show secret key
</button>
{showSecret && (
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-card-border bg-card px-3 py-2 font-mono text-xs tracking-wider break-all shadow-card-bevel">
{formatSecret(secret) || '...'}
</code>
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
<Copy className="w-4 h-4" strokeWidth={1.5} />
</Button>
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
{step === 'qr' && 'Pair your authenticator'}
{step === 'confirm' && 'Confirm the pairing'}
{step === 'backup' && 'Save your recovery codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Enrol a time-based one-time password (TOTP) authenticator and save
single-use backup codes.
</DialogDescription>
</DialogHeader>
<StepRail step={step} />
<div className="px-6 py-5">
{step === 'qr' && (
<div className="flex flex-col gap-4">
<p className="text-sm leading-snug text-stat-subtitle">
Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app.
</p>
<div className="flex justify-center rounded-md bg-background p-5 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.45)]">
{otpauthUri ? (
<QRCodeSVG value={otpauthUri} size={180} />
) : (
<div className="flex h-[180px] w-[180px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Secret · manual entry
</span>
<div className="flex items-stretch gap-2">
<code className="flex-1 truncate rounded-md border border-card-border bg-background/60 px-3 py-2 font-mono text-xs tabular-nums tracking-[0.2em] text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)]">
{formatSecret(secret) || '...'}
</code>
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
<Copy className="h-4 w-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<DialogFooter className="mt-2 gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
onClick={() => setStep('confirm')}
disabled={!otpauthUri || loading}
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
Continue
<ArrowRight strokeWidth={1.5} />
</Button>
</DialogFooter>
</div>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" onClick={() => setStep('confirm')} disabled={!otpauthUri || loading}>
Next
</Button>
</DialogFooter>
</div>
)}
{step === 'confirm' && (
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Enter the 6-digit code shown in your authenticator app to confirm enrolment.
</p>
<div className="grid gap-2">
<Label htmlFor="mfa-confirm-code">Verification code</Label>
<Input
id="mfa-confirm-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
maxLength={TOTP_LENGTH}
value={code}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>Back</Button>
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
{loading ? 'Verifying...' : 'Verify'}
</Button>
</DialogFooter>
</form>
)}
{step === 'confirm' && (
<div className="flex flex-col gap-4">
<p className="text-sm leading-snug text-stat-subtitle">
Enter the 6-digit code shown in your authenticator to confirm the pairing.
</p>
<OtpDigitField
id="mfa-confirm-code"
value={code}
onChange={handleCodeChange}
state={confirmState}
disabled={loading || confirmState === 'success'}
autoFocus
/>
{error && <ErrorRail>{error}</ErrorRail>}
<DialogFooter className="mt-2 gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>
Back
</Button>
</DialogFooter>
</div>
)}
{step === 'backup' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Each code can be used once if your authenticator is unavailable. Store them somewhere safe; they will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 rounded-md border border-card-border bg-card p-4 font-mono text-sm tabular-nums tracking-wider shadow-card-bevel">
{backupCodes.map((c) => (
<div key={c} className="text-center">{c}</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopyBackupCodes}>
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter>
<Button type="button" onClick={handleFinish}>I&apos;ve saved these</Button>
</DialogFooter>
{step === 'backup' && (
<div className="flex flex-col gap-4">
<p className="text-sm leading-snug text-stat-subtitle">
Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again.
</p>
<BackupCodeTicket codes={backupCodes} />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopyBackupCodes}>
<Copy className="h-4 w-4" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
<Download className="h-4 w-4" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
onClick={handleFinish}
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
<Check strokeWidth={1.5} />
Done
</Button>
</DialogFooter>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function StepRail({ step }: { step: Step }) {
const steps: { id: Step; label: string }[] = [
{ id: 'qr', label: 'Pair' },
{ id: 'confirm', label: 'Confirm' },
{ id: 'backup', label: 'Archive' },
];
const activeIndex = steps.findIndex((s) => s.id === step);
return (
<div className="grid grid-cols-3 border-b border-card-border/60">
{steps.map((s, i) => {
const isActive = i === activeIndex;
const isComplete = i < activeIndex;
return (
<div
key={s.id}
className={cn(
'relative flex items-center justify-center gap-2 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.18em]',
i < steps.length - 1 && 'border-r border-card-border/60',
isActive ? 'text-brand' : isComplete ? 'text-stat-subtitle' : 'text-stat-subtitle/60',
)}
>
{isComplete ? (
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-hidden />
) : (
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span>
)}
<span>{s.label}</span>
{isActive && (
<span aria-hidden className="absolute inset-x-3 bottom-0 h-[2px] bg-brand" />
)}
</div>
);
})}
</div>
);
}
function BackupCodeTicket({ codes }: { codes: string[] }) {
return (
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Recovery codes</span>
<span className="tabular-nums">{codes.length} issued</span>
</div>
<ol className="grid grid-cols-1 sm:grid-cols-2">
{codes.map((c, i) => (
<li
key={c}
className={cn(
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
'border-t border-card-border/40',
i === 0 && 'sm:border-t-0',
i === 1 && 'sm:border-t-0',
)}
>
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{String(i + 1).padStart(2, '0')}
</span>
<span>{c}</span>
</li>
))}
</ol>
</div>
);
}