diff --git a/e2e/mfa.spec.ts b/e2e/mfa.spec.ts index 3e3488fd..61104811 100644 --- a/e2e/mfa.spec.ts +++ b/e2e/mfa.spec.ts @@ -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. diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 03cadd35..b0eb6741 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -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 ( - + @@ -25,27 +30,23 @@ function getProviderIcon(provider: string) { ); case 'oidc_github': return ( - + ); case 'oidc_okta': return ( - + ); case 'oidc_custom': - return ; default: - return null; + return ; } } -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([]); + 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) => { + if (typeof e.getModifierState === 'function') { + setCapsLock(e.getModifierState('CapsLock')); + } + }; + return ( -
- {/* ── Left: Branding Panel (desktop only) ── */} -
- {/* Dot grid texture */} -
- - {/* Branding content */} -
- Sencho -
-

Sencho

-

Docker Compose Management

+
+ + Console · Local + Secure by default
-
- - {/* Brand accent line */} -
-
- - {/* ── Right: Form Panel ── */} -
- {/* Mobile logo header */} -
- Sencho - Sencho - Sencho -
- -
-
-

Welcome back

-

- Sign in to your Sencho instance -

-
- -
-
-
- - setUsername(e.target.value)} + } + > +
+
+ + {hasLdap && ( +
+ setLoginMode('local')} + /> + setLoginMode('ldap')} />
-
- - setPassword(e.target.value)} - /> -
- {error && ( -
- {error} -
- )} - - {hasLdap && ( - - )} + )} +
+ + +
+ + setUsername(e.target.value)} + className={INPUT_CLASS} + />
+ +
+
+ + {capsLock && ( + + Caps Lock On + + )} +
+ setPassword(e.target.value)} + onKeyDown={handlePasswordKey} + onKeyUp={handlePasswordKey} + className={INPUT_CLASS} + /> +
+ + {error && {error}} + + {oidcProviders.length > 0 && ( - <> -
-
-
-
-
- Or continue with -
+
+
+
+ + Or continue with + +
-
- {oidcProviders.map(p => ( +
+ {oidcProviders.map((p) => ( ))}
- +
)}
-
+
); } + +function ModePill({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) { + return ( + + ); +} + diff --git a/frontend/src/components/MfaChallenge.tsx b/frontend/src/components/MfaChallenge.tsx index 08495b4d..266c3e64 100644 --- a/frontend/src/components/MfaChallenge.tsx +++ b/frontend/src/components/MfaChallenge.tsx @@ -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('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 ( -
- {/* Branding panel (matches Login layout) */} -
-
-
- Sencho + + Console · Verify + +
+ } + > +
+ -
-

Sencho

-

Docker Compose Management

-
-
-
-
- {/* Form panel */} -
-
- Sencho - Sencho - Sencho -
- -
-
-

Two-factor authentication

-

- {useBackup - ? 'Enter one of your saved backup codes to continue.' - : 'Open your authenticator app and enter the 6-digit code.'} -

-
- -
-
-
- + {throttled ? ( + + ) : useBackup ? ( + +
+ + Backup code · 10 chars + 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" />
- {error && ( -
- {error} -
- )} - - - + + ) : ( +
+ + {error && {error}}
- + )} + +
+ +
+ ); +} + +function ThrottleTile({ seconds }: { seconds: number }) { + return ( +
+ +
+
+ Retry in + {formatSeconds(seconds)} +
+ + Rate limited +
); } + +function ModeToggle({ + useBackup, + onToggle, + disabled, +}: { + useBackup: boolean; + onToggle: () => void; + disabled: boolean; +}) { + return ( + + ); +} + diff --git a/frontend/src/components/Setup.tsx b/frontend/src/components/Setup.tsx index 4b75f18d..324991b5 100644 --- a/frontend/src/components/Setup.tsx +++ b/frontend/src/components/Setup.tsx @@ -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 ( -
- {/* ── Left: Branding Panel (desktop only) ── */} -
- {/* Dot grid texture */} -
- - {/* Branding content */} -
- Sencho + + Console · First boot + Empty database +
+ } + > +
+ -
-

Sencho

-

Docker Compose Management

-
-
- {/* Brand accent line */} -
-
+
+ + setUsername(e.target.value)} + className={INPUT_CLASS} + /> + - {/* ── Right: Form Panel ── */} -
- {/* Mobile logo header */} -
- Sencho - Sencho - Sencho -
- -
-
-

Create your account

-

- Set up the admin credentials for your Sencho instance -

-
- - -
-
- - setUsername(e.target.value)} - /> +
+
+ + {strength && ( + + {strength.label} + + )}
-
- - setPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
- {error && ( -
- {error} -
- )} - + setPassword(e.target.value)} + className={INPUT_CLASS} + />
+ + + setConfirmPassword(e.target.value)} + className={INPUT_CLASS} + /> + + + {error && {error}} + +
-
+
); } + +function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + diff --git a/frontend/src/components/auth/AuthCanvas.tsx b/frontend/src/components/auth/AuthCanvas.tsx new file mode 100644 index 00000000..1d580a24 --- /dev/null +++ b/frontend/src/components/auth/AuthCanvas.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface AuthCanvasProps extends React.HTMLAttributes { + footer?: React.ReactNode; +} + +export function AuthCanvas({ children, className, footer, ...props }: AuthCanvasProps) { + return ( +
+
+ +
+ + +
+ + SENCHO + + +
+ +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/auth/AuthStepHeader.tsx b/frontend/src/components/auth/AuthStepHeader.tsx new file mode 100644 index 00000000..0956a76b --- /dev/null +++ b/frontend/src/components/auth/AuthStepHeader.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface AuthStepHeaderProps extends React.HTMLAttributes { + kicker: string; + hero: string; + caption?: React.ReactNode; +} + +export function AuthStepHeader({ kicker, hero, caption, className, ...props }: AuthStepHeaderProps) { + return ( +
+ + {kicker} + +

+ {hero} +

+ {caption && ( +

{caption}

+ )} +
+ ); +} diff --git a/frontend/src/components/auth/ErrorRail.tsx b/frontend/src/components/auth/ErrorRail.tsx new file mode 100644 index 00000000..1ce80f2c --- /dev/null +++ b/frontend/src/components/auth/ErrorRail.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; + +export function ErrorRail({ children }: { children: ReactNode }) { + return ( +
+ +
Error
+
{children}
+
+ ); +} diff --git a/frontend/src/components/auth/OtpDigitField.tsx b/frontend/src/components/auth/OtpDigitField.tsx new file mode 100644 index 00000000..916273d4 --- /dev/null +++ b/frontend/src/components/auth/OtpDigitField.tsx @@ -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(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 ( +
inputRef.current?.focus()} + > + 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 ( +
+ {char || (isActive ? : null)} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx index a1957063..48c92e38 100644 --- a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx +++ b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx @@ -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([]); 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); }} > - - - - {step === 'confirm' ? 'Regenerate backup codes' : 'New backup codes'} - - - Replace your backup codes with a freshly generated set. The previous - set stops working immediately. - - + +
+ - {step === 'confirm' && ( -
-

- Your current backup codes will stop working immediately. Confirm with a code from your authenticator app to continue. -

-
- - handleCodeChange(e.target.value)} - className="font-mono tabular-nums tracking-widest text-center" - placeholder="123456" - /> + +
+ SENCHO · MFA
- {error &&
{error}
} - - - - - - )} + + {step === 'confirm' ? 'Confirm identity' : 'New recovery codes'} + + + Replace your backup codes with a freshly generated set. The previous + set stops working immediately. + +
- {step === 'show' && ( -
-

- Each code can be used once. Store them somewhere safe; they will not be shown again. -

-
- {backupCodes.map((c) => ( -
{c}
- ))} -
-
- - -
- - - +
+ {step === 'confirm' && ( +
+

+ Enter a code from your authenticator to generate a new set. The previous codes stop working immediately. +

+ + {error && {error}} + + + +
+ )} + + {step === 'show' && ( +
+ Previous codes have been invalidated. +

+ Each code can be used once. Store them safely. They will not be shown again. +

+ +
+ + +
+ + + +
+ )}
- )} +
); } + +function BackupCodeTicket({ codes }: { codes: string[] }) { + return ( +
+
+ Recovery codes + {codes.length} issued +
+
    + {codes.map((c, i) => ( +
  1. + + {String(i + 1).padStart(2, '0')} + + {c} +
  2. + ))} +
+
+ ); +} + +function WarningRail({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} + diff --git a/frontend/src/components/mfa/MfaDisableDialog.tsx b/frontend/src/components/mfa/MfaDisableDialog.tsx index 452cc918..0cf9271e 100644 --- a/frontend/src/components/mfa/MfaDisableDialog.tsx +++ b/frontend/src/components/mfa/MfaDisableDialog.tsx @@ -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 ( - - - Disable two-factor authentication? - - Your account will only be protected by a password. Anyone who obtains that password can sign in. Confirm with a current code to continue. - - + +
+ -
-
- - handleCodeChange(e.target.value)} - className="font-mono tabular-nums tracking-widest text-center" - placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'} - /> + +
+ SENCHO · MFA · DISABLE +
+ + Turn off two-factor + + + Disabling 2FA removes this login layer. Your backup codes become invalid. Confirm with a current code to proceed. + +
+ +
+ {useBackup ? ( +
+ + Backup code · 10 chars + + 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" + /> +
+ ) : ( + + )} + + + + {error && {error}}
- - {error &&
{error}
} -
- - Cancel - - + + Cancel + + +
); diff --git a/frontend/src/components/mfa/MfaEnrollDialog.tsx b/frontend/src/components/mfa/MfaEnrollDialog.tsx index a7a9fd2d..faf686c4 100644 --- a/frontend/src/components/mfa/MfaEnrollDialog.tsx +++ b/frontend/src/components/mfa/MfaEnrollDialog.tsx @@ -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([]); - // 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 { - // 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); }} > - - - - {step === 'qr' && 'Set up two-factor authentication'} - {step === 'confirm' && 'Confirm your authenticator'} - {step === 'backup' && 'Save your backup codes'} - - - Enrol a time-based one-time password (TOTP) authenticator and save - single-use backup codes. - - + +
+ - {step === 'qr' && ( -
-

- Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator. -

-
- {otpauthUri - ? - :
- } + +
+ SENCHO · MFA
- - {showSecret && ( -
- - {formatSecret(secret) || '...'} - - + + {step === 'qr' && 'Pair your authenticator'} + {step === 'confirm' && 'Confirm the pairing'} + {step === 'backup' && 'Save your recovery codes'} + + + Enrol a time-based one-time password (TOTP) authenticator and save + single-use backup codes. + + + + + +
+ {step === 'qr' && ( +
+

+ Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app. +

+
+ {otpauthUri ? ( + + ) : ( +
+ +
+ )} +
+
+ + Secret · manual entry + +
+ + {formatSecret(secret) || '...'} + + +
+
+ + + +
)} - - - - -
- )} - {step === 'confirm' && ( -
-

- Enter the 6-digit code shown in your authenticator app to confirm enrolment. -

-
- - handleCodeChange(e.target.value)} - className="font-mono tabular-nums tracking-widest text-center" - placeholder="123456" - /> -
- {error &&
{error}
} - - - - -
- )} + {step === 'confirm' && ( +
+

+ Enter the 6-digit code shown in your authenticator to confirm the pairing. +

+ + {error && {error}} + + + +
+ )} - {step === 'backup' && ( -
-

- Each code can be used once if your authenticator is unavailable. Store them somewhere safe; they will not be shown again. -

-
- {backupCodes.map((c) => ( -
{c}
- ))} -
-
- - -
- - - + {step === 'backup' && ( +
+

+ Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again. +

+ +
+ + +
+ + + +
+ )}
- )} +
); } + +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 ( +
+ {steps.map((s, i) => { + const isActive = i === activeIndex; + const isComplete = i < activeIndex; + return ( +
+ {isComplete ? ( + + ) : ( + {String(i + 1).padStart(2, '0')} + )} + {s.label} + {isActive && ( + + )} +
+ ); + })} +
+ ); +} + +function BackupCodeTicket({ codes }: { codes: string[] }) { + return ( +
+
+ Recovery codes + {codes.length} issued +
+
    + {codes.map((c, i) => ( +
  1. + + {String(i + 1).padStart(2, '0')} + + {c} +
  2. + ))} +
+
+ ); +} +