mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
feat(mfa): UX hardening — auto-submit, paste tolerance, low-codes warning, dev-mode diagnostics (#620)
* feat(mfa): auto-submit 6-digit TOTPs and normalize pasted backup codes Match the UX every major MFA prompt has (GitHub, GitLab, 1Password): the challenge screen and every code-entry dialog now submit automatically once the sixth TOTP digit lands, and the backup-code input accepts pastes with smart-dashes, trailing whitespace, or mixed case without silently truncating the value. Also caps the backup-code input at the correct 11 characters (10 plus a single separator) instead of 12. Shared normalization helpers live in frontend/src/lib/mfa.ts so the challenge and the three account-settings dialogs stay in lockstep. * feat(mfa): warn users when backup codes run low The Account & Security card silently showed a dim count of backup codes remaining, which meant users could drift toward zero without noticing until their phone was already lost. The card now surfaces a warning tone with an alert icon when 1 or 2 codes remain, and swaps to a dedicated destructive warning card with a "Regenerate now" action when the user has used every code. * feat(mfa): gate diagnostic logs behind developer mode Reuses the existing isDebugEnabled() gate so operators investigating a 2FA support ticket can flip Developer Mode on to get per-branch diagnostics (login path taken, replay check outcome, failure counter after a verify, replay-table purge counts), and flip it back off when they are done. Standard lifecycle logs stay on by default: enrolment completed, 2FA disabled, backup codes regenerated, admin reset, SSO bypass toggled, lockout engaged. Nothing that could reveal a TOTP code, base32 secret, backup-code cleartext, or partial-auth JWT is ever logged. * test(mfa): cover drift, invalid formats, lockout recovery, and paste normalization Backend: a TOTP generated for a window that has already slid out is rejected, malformed backup codes (too short, non-alphanumeric, 11-char alphanumeric that matches no hash) all increment failed_attempts, a successful verify clears a below-threshold failure streak, a successful verify after locked_until has passed clears the lockout, a second enroll/start overwrites the prior pending secret, and the backup-code normalizer treats en-dash/em-dash/figure-dash with stray whitespace the same as the canonical form. E2E: low-backup-codes warning renders in the warning tone and the exhausted-codes state flips to the dedicated warning card, a 6-digit TOTP auto-submits without a button click, and a backup code pasted without the separator still signs in. * docs(mfa): auto-submit, paste guidance, and expanded troubleshooting Document that the challenge screen submits automatically on the sixth digit, that backup codes accept the separator and any case, and that the Account & Security card nudges at low code counts. Expands the troubleshooting section with entries for lost or exhausted backup codes and adds a short note to the admin guide about surfacing auth diagnostics via Developer Mode.
This commit is contained in:
@@ -1,37 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { 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 {
|
||||
BACKUP_CODE_DISPLAY_LENGTH,
|
||||
TOTP_LENGTH,
|
||||
normalizeBackupCodeInput,
|
||||
normalizeTotpInput,
|
||||
} from '@/lib/mfa';
|
||||
|
||||
export function MfaChallenge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const { submitMfa, cancelMfa } = useAuth();
|
||||
const [code, setCode] = useState('');
|
||||
// `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 [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 submittedRef = useRef(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const runSubmit = async (valueToSubmit: string) => {
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
const result = await submitMfa(code, { isBackupCode: useBackup });
|
||||
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);
|
||||
setCode('');
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
submittedRef.current = false;
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isLoading || !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 normalized = normalizeTotpInput(value);
|
||||
setDisplay(normalized);
|
||||
setRaw(normalized);
|
||||
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
|
||||
if (
|
||||
normalized.length === TOTP_LENGTH &&
|
||||
!isLoading &&
|
||||
!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 handleToggleBackup = () => {
|
||||
setUseBackup((v) => !v);
|
||||
setCode('');
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
setError('');
|
||||
submittedRef.current = false;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,9 +136,9 @@ export function MfaChallenge({
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={useBackup ? 12 : 6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
|
||||
value={display}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="font-mono tabular-nums tracking-widest text-center"
|
||||
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
|
||||
/>
|
||||
@@ -101,7 +148,7 @@ export function MfaChallenge({
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !code}>
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !raw}>
|
||||
{isLoading ? 'Verifying...' : 'Verify and sign in'}
|
||||
</Button>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
|
||||
|
||||
interface MfaBackupCodesDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,38 +22,63 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
const resetState = () => {
|
||||
setStep('confirm');
|
||||
setCode('');
|
||||
setError('');
|
||||
setBackupCodes([]);
|
||||
submittedRef.current = false;
|
||||
};
|
||||
|
||||
const handleConfirm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const submitRegenerate = async (valueToSubmit: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/auth/mfa/backup-codes/regenerate', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ code }),
|
||||
body: JSON.stringify({ code: valueToSubmit }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error || 'Could not regenerate backup codes');
|
||||
setCode('');
|
||||
submittedRef.current = false;
|
||||
return;
|
||||
}
|
||||
setBackupCodes(data.backupCodes || []);
|
||||
setStep('show');
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Could not regenerate backup codes');
|
||||
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 &&
|
||||
!loading &&
|
||||
!submittedRef.current
|
||||
) {
|
||||
submittedRef.current = true;
|
||||
requestAnimationFrame(() => { void submitRegenerate(normalized); });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodes.join('\n'));
|
||||
@@ -119,9 +145,9 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={6}
|
||||
maxLength={TOTP_LENGTH}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={(e) => handleCodeChange(e.target.value)}
|
||||
className="font-mono tabular-nums tracking-widest text-center"
|
||||
placeholder="123456"
|
||||
/>
|
||||
@@ -129,7 +155,7 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
|
||||
{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 !== 6}>
|
||||
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
|
||||
{loading ? 'Working...' : 'Regenerate'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -13,6 +13,13 @@ 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 {
|
||||
BACKUP_CODE_DISPLAY_LENGTH,
|
||||
BACKUP_CODE_RAW_LENGTH,
|
||||
TOTP_LENGTH,
|
||||
normalizeBackupCodeInput,
|
||||
normalizeTotpInput,
|
||||
} from '@/lib/mfa';
|
||||
|
||||
interface MfaDisableDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,44 +28,94 @@ interface MfaDisableDialogProps {
|
||||
}
|
||||
|
||||
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
|
||||
const [code, setCode] = useState('');
|
||||
// `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 submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCode('');
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
setError('');
|
||||
setUseBackup(false);
|
||||
submittedRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleDisable = async () => {
|
||||
const submitDisable = async (valueToSubmit: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/auth/mfa/disable', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ code, isBackupCode: useBackup }),
|
||||
body: JSON.stringify({ code: valueToSubmit, isBackupCode: useBackup }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error || 'Could not disable two-factor authentication');
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
submittedRef.current = false;
|
||||
return;
|
||||
}
|
||||
toast.success('Two-factor authentication disabled');
|
||||
setCode('');
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
onOpenChange(false);
|
||||
onDisabled();
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Could not disable two-factor authentication');
|
||||
submittedRef.current = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 normalized = normalizeTotpInput(value);
|
||||
setDisplay(normalized);
|
||||
setRaw(normalized);
|
||||
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
|
||||
if (
|
||||
normalized.length === TOTP_LENGTH &&
|
||||
!loading &&
|
||||
!submittedRef.current
|
||||
) {
|
||||
submittedRef.current = true;
|
||||
requestAnimationFrame(() => { void submitDisable(normalized); });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBackup = () => {
|
||||
setUseBackup((v) => !v);
|
||||
setDisplay('');
|
||||
setRaw('');
|
||||
setError('');
|
||||
submittedRef.current = false;
|
||||
};
|
||||
|
||||
const handleDisableClick = () => {
|
||||
if (loading || raw.length !== expectedLength) return;
|
||||
submittedRef.current = true;
|
||||
void submitDisable(raw);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
@@ -77,9 +134,9 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
type="text"
|
||||
inputMode={useBackup ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
maxLength={useBackup ? 12 : 6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
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'}
|
||||
/>
|
||||
@@ -87,7 +144,7 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
|
||||
onClick={() => { setUseBackup((v) => !v); setCode(''); setError(''); }}
|
||||
onClick={handleToggleBackup}
|
||||
>
|
||||
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
|
||||
</button>
|
||||
@@ -100,8 +157,8 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
disabled={loading || !code}
|
||||
onClick={handleDisable}
|
||||
disabled={loading || raw.length !== expectedLength}
|
||||
onClick={handleDisableClick}
|
||||
>
|
||||
{loading ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Copy, Download, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
|
||||
|
||||
interface MfaEnrollDialogProps {
|
||||
open: boolean;
|
||||
@@ -33,6 +34,8 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
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(() => {
|
||||
@@ -43,6 +46,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
setError('');
|
||||
setBackupCodes([]);
|
||||
setShowSecret(false);
|
||||
submittedRef.current = false;
|
||||
setLoading(true);
|
||||
apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true })
|
||||
.then(async (r) => {
|
||||
@@ -65,30 +69,53 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
return () => { cancelled = true; };
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleConfirm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const submitConfirm = async (valueToSubmit: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/auth/mfa/enroll/confirm', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ code }),
|
||||
body: JSON.stringify({ code: valueToSubmit }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data?.error || 'Verification failed');
|
||||
setCode('');
|
||||
submittedRef.current = false;
|
||||
return;
|
||||
}
|
||||
setBackupCodes(data.backupCodes || []);
|
||||
setStep('backup');
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Verification failed');
|
||||
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 &&
|
||||
!loading &&
|
||||
!submittedRef.current
|
||||
) {
|
||||
submittedRef.current = true;
|
||||
requestAnimationFrame(() => { void submitConfirm(normalized); });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
@@ -202,9 +229,9 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={6}
|
||||
maxLength={TOTP_LENGTH}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={(e) => handleCodeChange(e.target.value)}
|
||||
className="font-mono tabular-nums tracking-widest text-center"
|
||||
placeholder="123456"
|
||||
/>
|
||||
@@ -212,7 +239,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
{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 !== 6}>
|
||||
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RefreshCw, Shield, ShieldCheck } from 'lucide-react';
|
||||
import { AlertTriangle, RefreshCw, Shield, ShieldCheck } from 'lucide-react';
|
||||
import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog';
|
||||
import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog';
|
||||
import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog';
|
||||
@@ -144,9 +144,36 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
|
||||
<div className="mt-4 text-xs text-muted-foreground">Loading…</div>
|
||||
) : mfa?.enabled ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining
|
||||
</div>
|
||||
{mfa.backupCodesRemaining === 0 ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-destructive" strokeWidth={1.5} />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-destructive">No backup codes left</div>
|
||||
<div className="text-xs text-destructive/80 mt-0.5">
|
||||
Regenerate a new set before you lose access to your authenticator app. Without codes, recovery needs an administrator.
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 h-7 px-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setRegenOpen(true)}
|
||||
>
|
||||
Regenerate now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : mfa.backupCodesRemaining <= 2 ? (
|
||||
<div className="flex items-center gap-2 text-xs font-mono tabular-nums text-warning">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span>
|
||||
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining, regenerate a fresh set
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{mfa.backupCodesRemaining} backup codes remaining
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSso && (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-card-border bg-background/40 p-3">
|
||||
|
||||
Reference in New Issue
Block a user