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
+58 -11
View File
@@ -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