feat(web): account security page and MFA enrolment wizard

This commit is contained in:
2026-09-16 09:26:54 +00:00
parent 26825841fa
commit 32fd11cde7
5 changed files with 409 additions and 18 deletions
+2 -18
View File
@@ -5,6 +5,7 @@ import QRCode from "qrcode";
import { auth } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { Button } from "@/components/ui";
import { RecoveryCodes } from "./RecoveryCodes";
/**
* The calls a wizard step makes to set up a second factor. Defaults to the
@@ -53,7 +54,6 @@ export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnr
// Recovery codes state
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [savedConfirmed, setSavedConfirmed] = useState(false);
if (!api) {
return <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">This wizard was not given its endpoints for session mode.</div>;
@@ -165,21 +165,5 @@ export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnr
}
// step === "recovery"
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor.</p>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
{recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
I have saved these codes
</label>
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={() => onComplete(recoveryCodes)}>
Continue
</Button>
</div>
);
return <RecoveryCodes codes={recoveryCodes} onAcknowledge={() => onComplete(recoveryCodes)} />;
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
interface RecoveryCodesProps {
codes: string[];
onAcknowledge: () => void;
}
/**
* Shows a fresh batch of recovery codes exactly once, either at the end of
* enrolment (via MfaEnrolWizard) or after a regenerate on the security page.
* The server never returns a previously issued batch, so this is the only
* place in the app these codes are ever visible.
*/
export function RecoveryCodes({ codes, onAcknowledge }: RecoveryCodesProps) {
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
async function copyAll() {
await navigator.clipboard.writeText(codes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function downloadAll() {
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vantage-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor. They are shown here exactly once.
</p>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
{codes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={copyAll}>
{copied ? "Copied" : "Copy all"}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={downloadAll}>
Download as .txt
</Button>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
I have saved these codes
</label>
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={onAcknowledge}>
Continue
</Button>
</div>
);
}