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:
Anso
2026-04-15 19:51:44 -04:00
committed by GitHub
parent a43c203d7b
commit 4722028904
12 changed files with 600 additions and 53 deletions
@@ -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">