feat(web): account security page and MFA enrolment wizard
This commit is contained in:
@@ -204,6 +204,7 @@ const navGroups: NavGroup[] = [
|
||||
// keys, capped at their own role, so gating the page would hide a
|
||||
// capability they have.
|
||||
{ href: "/tokens", label: "API Keys", icon: <TokenIcon /> },
|
||||
{ href: "/account/security", label: "Security", icon: <ShieldIcon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user