mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
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:
+30
-30
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user