From 32fd11cde73635e80021f67375cfc98bd9c4c55d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 09:26:54 +0000 Subject: [PATCH] feat(web): account security page and MFA enrolment wizard --- web/app/(app)/account/security/page.tsx | 278 ++++++++++++++++++++++++ web/components/Sidebar.tsx | 1 + web/components/mfa/MfaEnrolWizard.tsx | 20 +- web/components/mfa/RecoveryCodes.tsx | 64 ++++++ web/lib/api.ts | 64 ++++++ 5 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 web/app/(app)/account/security/page.tsx create mode 100644 web/components/mfa/RecoveryCodes.tsx diff --git a/web/app/(app)/account/security/page.tsx b/web/app/(app)/account/security/page.tsx new file mode 100644 index 0000000..5c5b5f1 --- /dev/null +++ b/web/app/(app)/account/security/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { me, type MePasskey } from "@/lib/api"; +import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn"; +import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, ConfirmDialog, friendlyMessage, Modal, useToast } from "@/components/ui"; +import { MfaEnrolWizard, type MfaEnrolEndpoints } from "@/components/mfa/MfaEnrolWizard"; +import { RecoveryCodes } from "@/components/mfa/RecoveryCodes"; + +/** + * Adapts the /api/me/* client methods to the shape MfaEnrolWizard expects. + * The only mismatch is the field name: the server calls it `otpauth_uri` + * here (it is `otpauth_url` on the ticket-scoped enrolment endpoints from + * Task 10, which this page does not use). + */ +const SESSION_ENDPOINTS: MfaEnrolEndpoints = { + totpSetup: async () => { + const res = await me.totpSetup(); + return { secret: res.secret, otpauth_url: res.otpauth_uri }; + }, + totpConfirm: (code) => me.totpConfirm(code), + passkeyBegin: () => me.passkeyRegisterBegin(), + passkeyFinish: (ceremonyId, credential, name) => me.passkeyRegisterFinish(ceremonyId, credential, name), +}; + +function formatDate(value?: string): string { + if (!value) return "Never"; + return new Date(value).toLocaleString(); +} + +export default function SecurityPage() { + const queryClient = useQueryClient(); + const toast = useToast(); + + const { data: status, isLoading, error } = useQuery({ queryKey: ["me-mfa"], queryFn: me.mfa }); + + const [wizardOpen, setWizardOpen] = useState(false); + const [removingTotp, setRemovingTotp] = useState(false); + const [removingPasskey, setRemovingPasskey] = useState(null); + const [renamingPasskey, setRenamingPasskey] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [newRecoveryCodes, setNewRecoveryCodes] = useState(null); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["me-mfa"] }); + + const removeTotp = useMutation({ + mutationFn: () => me.removeTotp(), + onSuccess: () => { + invalidate(); + toast.success("Authenticator app removed."); + setRemovingTotp(false); + }, + }); + + const deletePasskey = useMutation({ + mutationFn: (id: string) => me.deletePasskey(id), + onSuccess: (_data, id) => { + invalidate(); + toast.success("Passkey removed."); + if (removingPasskey?.id === id) setRemovingPasskey(null); + }, + }); + + const renamePasskey = useMutation({ + mutationFn: () => me.renamePasskey(renamingPasskey!.id, renameValue), + onSuccess: () => { + invalidate(); + setRenamingPasskey(null); + }, + }); + + const regenerateRecovery = useMutation({ + mutationFn: () => me.regenerateRecovery(), + onSuccess: (res) => setNewRecoveryCodes(res.recovery_codes), + }); + + const addPasskeyDirect = useMutation({ + mutationFn: async () => { + const { publicKey, ceremony_id } = await me.passkeyRegisterBegin(); + const cred = (await navigator.credentials.create({ publicKey: toCreateOptions(publicKey) })) as PublicKeyCredential; + return me.passkeyRegisterFinish(ceremony_id, credentialToJSON(cred)); + }, + onSuccess: () => invalidate(), + }); + + function factorCount(s: NonNullable): number { + return (s.totp_enabled ? 1 : 0) + s.passkeys.length; + } + + return ( +
+

Security

+ + + {status && !status.applicable ? ( + +

+ Your sign-in is managed by this instance's identity provider. Two-factor authentication and passkeys are configured there, not here. +

+
+ ) : status ? ( + <> + {status.require_mfa && ( +
+ This instance requires a second sign-in factor. You cannot remove your last one. +
+ )} + + + + Authenticator app + {status.totp_enabled ? "Enabled" : "Not set up"} + +

Generates a 6-digit code every 30 seconds in an app like Google Authenticator or 1Password.

+ {status.totp_enabled ? ( + + ) : ( + + )} +
+ + + + Passkeys + {isPasskeySupported() && ( + + )} + + {addPasskeyDirect.error &&

{friendlyMessage(addPasskeyDirect.error)}

} + {status.passkeys.length === 0 ? ( +

No passkeys registered.

+ ) : ( +
    + {status.passkeys.map((pk) => { + const lastFactor = status.require_mfa && !status.totp_enabled && status.passkeys.length <= 1; + return ( +
  • +
    +

    {pk.name}

    +

    + Added {formatDate(pk.created_at)} ยท Last used {formatDate(pk.last_used_at)} +

    +
    +
    + + +
    +
  • + ); + })} +
+ )} +
+ + + + Recovery codes + +

+ {status.recovery_remaining} unused code{status.recovery_remaining === 1 ? "" : "s"} remaining. Regenerating invalidates every existing code. +

+ + {regenerateRecovery.error &&

{friendlyMessage(regenerateRecovery.error)}

} +
+ + ) : null} +
+ + setWizardOpen(false)}> + setWizardOpen(false)} + onComplete={() => { + invalidate(); + setWizardOpen(false); + }} + /> + + + setNewRecoveryCodes(null)}> + {newRecoveryCodes && ( + { + invalidate(); + setNewRecoveryCodes(null); + }} + /> + )} + + + setRenamingPasskey(null)}> +
{ + e.preventDefault(); + renamePasskey.mutate(); + }} + > + setRenameValue(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> + {renamePasskey.error &&

{friendlyMessage(renamePasskey.error)}

} +
+ + +
+
+
+ + setRemovingTotp(false)} + onConfirm={() => removeTotp.mutate()} + body={

You will no longer be asked for a code from your authenticator app when signing in.

} + /> + + setRemovingPasskey(null)} + onConfirm={() => removingPasskey && deletePasskey.mutate(removingPasskey.id)} + body={ +

+ {removingPasskey?.name} will no longer be accepted for sign-in. +

+ } + /> +
+ ); +} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index f696ac8..ee858e8 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -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: }, + { href: "/account/security", label: "Security", icon: }, ], }, { diff --git a/web/components/mfa/MfaEnrolWizard.tsx b/web/components/mfa/MfaEnrolWizard.tsx index 3733a2c..16609db 100644 --- a/web/components/mfa/MfaEnrolWizard.tsx +++ b/web/components/mfa/MfaEnrolWizard.tsx @@ -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([]); - const [savedConfirmed, setSavedConfirmed] = useState(false); if (!api) { return
This wizard was not given its endpoints for session mode.
; @@ -165,21 +165,5 @@ export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnr } // step === "recovery" - return ( -
-

Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor.

-
- {recoveryCodes.map((code) => ( -
{code}
- ))} -
- - -
- ); + return onComplete(recoveryCodes)} />; } diff --git a/web/components/mfa/RecoveryCodes.tsx b/web/components/mfa/RecoveryCodes.tsx new file mode 100644 index 0000000..0b4978a --- /dev/null +++ b/web/components/mfa/RecoveryCodes.tsx @@ -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 ( +
+

+ 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. +

+
+ {codes.map((code) => ( +
{code}
+ ))} +
+
+ + +
+ + +
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index fcd94a0..d460ae9 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -790,6 +790,70 @@ export const auth = { }, }; +export interface MePasskey { + id: string; + name: string; + created_at: string; + last_used_at?: string; + transports?: string[]; +} + +export interface MeMfaStatus { + totp_enabled: boolean; + passkeys: MePasskey[]; + recovery_remaining: number; + /** The instance's require_mfa policy: a removal leaving no factor is refused. */ + require_mfa: boolean; + /** False when an identity provider owns this user's authentication. */ + applicable: boolean; +} + +/** + * The signed-in user's own factors, at /api/me/*. Several of these are + * guarded by step-up and answer 403 `step_up_required`; that interception is + * handled globally elsewhere, not here. + */ +export const me = { + mfa(): Promise { + return request("/me/mfa"); + }, + + totpSetup(): Promise<{ secret: string; otpauth_uri: string }> { + return request("/me/mfa/totp/setup", { method: "POST" }); + }, + + totpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> { + return request("/me/mfa/totp/confirm", { method: "POST", body: JSON.stringify({ code }) }); + }, + + removeTotp(): Promise { + return request("/me/mfa/totp", { method: "DELETE" }); + }, + + regenerateRecovery(): Promise<{ recovery_codes: string[] }> { + return request("/me/mfa/recovery/regenerate", { method: "POST" }); + }, + + passkeyRegisterBegin(): Promise<{ publicKey: any; ceremony_id: string }> { + return request("/me/passkeys/begin", { method: "POST" }); + }, + + passkeyRegisterFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> { + return request("/me/passkeys/finish", { + method: "POST", + body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }), + }); + }, + + renamePasskey(id: string, name: string): Promise { + return request(`/me/passkeys/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }); + }, + + deletePasskey(id: string): Promise { + return request(`/me/passkeys/${id}`, { method: "DELETE" }); + }, +}; + export const api = { listInstanceUsers(): Promise { return request("/instance/users");