fix: prevent MFA lockout and move enrollment to modal dialog
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m47s

Three fixes for MFA enrollment and sign-in:

- Defer TOTP registration with Logto until after 6-digit code verification.
  Previously setupTotp() immediately registered the secret, so abandoning
  enrollment mid-way left MFA active without a working authenticator.
- Move entire MFA enrollment flow (QR code, verify, backup codes) into a
  Modal dialog instead of replacing the Card content inline.
- Fix sign-in MFA flow: submitMfa() no longer calls identifyUser() after
  TOTP verify — user is already identified, and passing the MFA
  verificationId to identification returned 422 ("method not activated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 16:25:15 +02:00
parent 9231a1fc60
commit a5c20830a7
4 changed files with 168 additions and 144 deletions

View File

@@ -269,8 +269,9 @@ export async function verifyBackupCode(code: string): Promise<string> {
return data.verificationId;
}
export async function submitMfa(verificationId: string): Promise<string> {
await identifyUser(verificationId);
export async function submitMfa(_verificationId: string): Promise<string> {
// User is already identified from the initial sign-in step.
// MFA verification is stored in the experience session — just submit.
return submitInteraction();
}

View File

@@ -8,6 +8,7 @@ import {
Card,
FormField,
Input,
Modal,
Spinner,
useToast,
} from '@cameleer/design-system';
@@ -34,6 +35,8 @@ export function MfaSection() {
const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const modalOpen = !!setupData || !!codes;
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
@@ -87,6 +90,19 @@ export function MfaSection() {
}
}
function handleModalClose() {
// During backup codes step, only allow close after confirming saved
if (codes) return;
// During setup, safe to cancel — TOTP is not registered until verified
setSetupData(null);
setVerifyCode('');
}
function handleBackupCodesDone() {
setCodes(null);
setCodesSaved(false);
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
@@ -114,150 +130,146 @@ export function MfaSection() {
);
}
// Backup codes display
if (codes) {
return (
return (
<>
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
{setupData.secretQrCode.startsWith('data:') ? (
<img src={setupData.secretQrCode} alt="TOTP QR Code" width={200} height={200} />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<QRCodeSVG value={setupData.secretQrCode} size={200} />
<Badge label="Not enrolled" color="auto" />
)}
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
<Modal
open={modalOpen}
onClose={handleModalClose}
title={codes ? 'Save your backup codes' : 'Set up authenticator'}
size="md"
>
{codes ? (
<>
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={handleBackupCodesDone}>
Done
</Button>
</div>
</>
) : setupData ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={handleModalClose}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
</form>
</>
) : null}
</Modal>
</>
);
}