feat(security): add a TOTP and security-key setup hub

Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.

The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:36 +02:00
1 parent 6d053c3667
commit 402ea90d45
32 files changed
+3246 -401

No files matched your search

@@ -0,0 +1,72 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
interface MfaStatus {
totp: { enabled: boolean };
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
}
interface PasskeyInfo {
id: string;
}
export function SecuritySection() {
const { isAuthenticated } = useAuth();
const { data: status, isLoading } = useQuery({
queryKey: queryKeys.mfaStatus(),
queryFn: async () => apiGet<MfaStatus>("/api/auth/me/mfa"),
enabled: isAuthenticated,
});
// Passkey count drives whether the upgrade nudge shows. Shares the cached
// `passkeys()` key with the list below, so this is not an extra round-trip.
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
enabled: isAuthenticated,
});
const showNudge =
status != null &&
!status.passkeyNudgeDismissed &&
passkeys != null &&
passkeys.length === 0;
if (isLoading || !status) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Fingerprint,
KeyRound,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
lastUsedAt: string | null;
}
/**
* Primary-passkey management: list (name, device, backup, created, last used),
* add (registration ceremony), rename, and delete. The passwordless-primary
* passkey home — distinct from the second-factor security keys above.
*/
export function PasskeyListSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
enabled: isAuthenticated,
});
const add = useMutation({
mutationFn: async () => {
const { startRegistration } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options");
const credential = await startRegistration({ optionsJSON: options });
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
},
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err) => {
if (err instanceof ApiError) {
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
} else {
const { key, params } = describePasskeyError(err);
setMsg(t(key, params));
}
},
});
const rename = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
await apiPatch(`/api/auth/passkeys/${id}`, { name });
},
onSuccess: () => {
setEditingId(null);
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
return (
<SettingsCard>
<SettingsCardHeader
icon={Fingerprint}
title={t("settings.passkeys")}
description={t("settings.passkeysDescription")}
/>
<div className="mt-4">
{!passkeys || passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.noPasskeys")}
</p>
) : (
<ul className="space-y-2" data-testid="passkeys-list">
{passkeys.map((pk) => (
<SettingsCard as="li" key={pk.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{editingId === pk.id ? (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
rename.mutate({ id: pk.id, name: editName.trim() });
}}
>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
maxLength={64}
aria-label={t("settings.passkeyName")}
autoFocus
/>
<Button
type="submit"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
disabled={
rename.isPending || editName.trim().length === 0
}
aria-label={t("common.save")}
>
{rename.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel")}
>
<X className="h-4 w-4" />
</Button>
</form>
) : (
<>
<p className="truncate text-sm font-medium">
{pk.name}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={
pk.credentialBackedUp ? "secondary" : "outline"
}
className="text-xs"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
<span className="text-muted-foreground">
{pk.lastUsedAt
? t("settings.security.keys.lastUsed", {
date: formatDate(pk.lastUsedAt),
})
: t("settings.security.keys.neverUsed")}
</span>
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={() => {
setEditingId(pk.id);
setEditName(pk.name);
}}
aria-label={t("settings.security.keys.rename")}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(pk.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</SettingsCard>
))}
</ul>
)}
<div className="mt-4">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate()}
disabled={add.isPending}
>
{add.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
</div>
{msg && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{msg}
</div>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
/**
* One-time, dismissible "add a passkey" prompt. Shown only when the account
* has no passkey registered and has not dismissed the nudge before. Dismissal
* is persisted on the user (`passkeyUpgradeNudgeDismissed`).
*/
export function PasskeyUpgradeNudge() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dismiss = useMutation({
mutationFn: async () => {
await apiPost("/api/auth/me/passkey-nudge/dismiss");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
});
return (
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t("settings.security.passkeyNudge.title")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.security.passkeyNudge.body")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => dismiss.mutate()}
disabled={dismiss.isPending}
aria-label={t("common.dismiss")}
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Check, Copy, Download, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* One-time display of a freshly generated recovery-code set.
*
* Recovery codes are shown exactly once (the server only stores their hashes),
* so this panel leads with an explicit "save these now" warning and offers
* copy + download so the user cannot lose them to a closed tab. The caller
* owns when the panel disappears (e.g. an explicit "I've saved them" action).
*/
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
const asText = codes.join("\n");
async function handleCopy() {
try {
await navigator.clipboard.writeText(asText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard can be blocked (insecure context / permissions); the codes
// stay visible and downloadable, so this is a non-fatal best-effort.
}
}
function handleDownload() {
const blob = new Blob([`${asText}\n`], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="border-border bg-muted/30 mt-4 rounded-lg border p-4">
<div className="text-destructive flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm font-medium">
{t("settings.security.recovery.saveNow")}
</p>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.security.recovery.saveNowHint")}
</p>
<ul
className="mt-3 grid grid-cols-2 gap-2 font-mono text-sm"
data-testid="recovery-codes"
>
{codes.map((code) => (
<li
key={code}
className="border-border bg-background rounded border px-2 py-1.5 text-center tracking-wider"
>
{code}
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? t("common.copied") : t("common.copy")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
onClick={handleDownload}
>
<Download className="h-3.5 w-3.5" />
{t("settings.security.recovery.download")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Loader2,
Pencil,
Trash2,
Usb,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { formatDate } from "@/lib/format";
export interface WebauthnKeyInfo {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
}
function describeStepUp(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const add = useMutation({
mutationFn: async () => {
const { startRegistration } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/me/mfa/webauthn/register/options");
const credential = await startRegistration({ optionsJSON: options });
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
challengeId,
credential,
});
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) => {
if (err instanceof ApiError) {
setError(err.message || t("settings.security.keys.addFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
}
},
});
const rename = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
await apiPatch(`/api/auth/me/mfa/webauthn/${id}`, { name });
},
onSuccess: () => {
setEditingId(null);
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
err instanceof ApiError
? err.message
: t("settings.security.keys.renameFailed"),
),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeStepUp(
err,
t("settings.security.keys.removeFailed"),
t("settings.security.stepUpRequired"),
),
),
});
return (
<SettingsCard>
<SettingsCardHeader
icon={Usb}
title={t("settings.security.keys.title")}
description={t("settings.security.keys.description")}
/>
<div className="mt-4">
{keys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.security.keys.empty")}
</p>
) : (
<ul className="space-y-2" data-testid="security-keys-list">
{keys.map((key) => (
<SettingsCard as="li" key={key.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{editingId === key.id ? (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
rename.mutate({ id: key.id, name: editName.trim() });
}}
>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
maxLength={64}
aria-label={t("settings.security.keys.nameLabel")}
autoFocus
/>
<Button
type="submit"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
disabled={
rename.isPending || editName.trim().length === 0
}
aria-label={t("common.save")}
>
{rename.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel")}
>
<X className="h-4 w-4" />
</Button>
</form>
) : (
<>
<p className="truncate text-sm font-medium">
{key.name}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{key.lastUsedAt
? t("settings.security.keys.lastUsed", {
date: formatDate(key.lastUsedAt),
})
: t("settings.security.keys.neverUsed")}
{" · "}
{t("settings.security.keys.added", {
date: formatDate(key.createdAt),
})}
</p>
</>
)}
</div>
{editingId !== key.id && (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={() => {
setEditingId(key.id);
setEditName(key.name);
}}
aria-label={t("settings.security.keys.rename")}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.security.keys.remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.keys.remove")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.keys.removeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(key.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</SettingsCard>
))}
</ul>
)}
<div className="mt-4">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate()}
disabled={add.isPending}
>
{add.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Usb className="h-4 w-4" />
)}
{t("settings.security.keys.add")}
</Button>
</div>
{error && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,449 @@
"use client";
import { useId, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Copy,
KeyRound,
Loader2,
Smartphone,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { RecoveryCodesPanel } from "./recovery-codes-panel";
interface SetupData {
otpauthUri: string;
totpSecret: string;
}
interface ConfirmResult {
enabled: boolean;
recoveryCodes: string[];
recoveryCodesRemaining: number;
}
/** Map a step-up 401 to a clear re-login message; otherwise the raw message. */
function describeError(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function TotpCard({
enabled,
recoveryCodesRemaining,
}: {
enabled: boolean;
recoveryCodesRemaining: number;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const codeFieldId = useId();
const [setup, setSetup] = useState<SetupData | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [code, setCode] = useState("");
const [secretCopied, setSecretCopied] = useState(false);
const [freshCodes, setFreshCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [disableCode, setDisableCode] = useState("");
const [disableMethod, setDisableMethod] = useState<"totp" | "recovery">(
"totp",
);
function resetWizard() {
setSetup(null);
setQrDataUrl(null);
setCode("");
setSecretCopied(false);
setError(null);
}
const beginSetup = useMutation({
mutationFn: async () => {
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
},
onSuccess: async (data) => {
setError(null);
setSetup(data);
try {
const QRCode = (await import("qrcode")).default;
setQrDataUrl(await QRCode.toDataURL(data.otpauthUri, { margin: 1 }));
} catch {
// QR render is best-effort — the manual secret below is the fallback.
setQrDataUrl(null);
}
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
),
),
});
const confirm = useMutation({
mutationFn: async () => {
return apiPost<ConfirmResult>("/api/auth/me/mfa/totp/confirm", { code });
},
onSuccess: (data) => {
setError(null);
setFreshCodes(data.recoveryCodes);
resetWizard();
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.invalidCode"),
t("settings.security.stepUpRequired"),
),
),
});
const disable = useMutation({
mutationFn: async () => {
await apiPost("/api/auth/me/mfa/disable", {
code: disableCode,
method: disableMethod,
});
},
onSuccess: () => {
setDisableCode("");
setFreshCodes(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.disableFailed"),
t("settings.security.stepUpRequired"),
),
),
});
const regenerate = useMutation({
mutationFn: async () => {
return apiPost<{ recoveryCodes: string[] }>(
"/api/auth/me/mfa/recovery-codes/regenerate",
);
},
onSuccess: (data) => {
setError(null);
setFreshCodes(data.recoveryCodes);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.recovery.regenFailed"),
t("settings.security.stepUpRequired"),
),
),
});
async function copySecret() {
if (!setup) return;
try {
await navigator.clipboard.writeText(setup.totpSecret);
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
} catch {
/* clipboard blocked — the secret stays visible for manual entry */
}
}
return (
<SettingsCard>
<SettingsCardHeader
icon={Smartphone}
title={t("settings.security.totp.title")}
description={t("settings.security.totp.description")}
status={
enabled ? (
<Badge variant="secondary">{t("settings.security.active")}</Badge>
) : (
<Badge variant="outline">{t("settings.security.inactive")}</Badge>
)
}
/>
<div className="mt-4">
{/* ── Not enabled, no wizard: the enable CTA ── */}
{!enabled && !setup && !freshCodes && (
<Button
type="button"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate()}
disabled={beginSetup.isPending}
>
{beginSetup.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.security.totp.setUp")}
</Button>
)}
{/* ── Enable wizard ── */}
{setup && (
<div className="space-y-4">
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
<li>{t("settings.security.totp.step1")}</li>
<li>{t("settings.security.totp.step2")}</li>
</ol>
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
{qrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={qrDataUrl}
alt={t("settings.security.totp.qrAlt")}
width={176}
height={176}
className="border-border rounded-lg border bg-white p-2"
/>
) : (
<div className="border-border text-muted-foreground flex h-44 w-44 items-center justify-center rounded-lg border text-xs">
{t("settings.security.totp.qrUnavailable")}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">
{t("settings.security.totp.manualEntry")}
</p>
<div className="mt-1 flex items-center gap-2">
<code className="border-border bg-muted/40 min-w-0 flex-1 truncate rounded border px-2 py-1.5 font-mono text-xs">
{setup.totpSecret}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={copySecret}
aria-label={t("common.copy")}
>
{secretCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
confirm.mutate();
}}
>
<Label htmlFor={codeFieldId}>
{t("settings.security.totp.enterCode")}
</Label>
<div className="flex gap-2">
<Input
id={codeFieldId}
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="font-mono tracking-widest"
aria-invalid={!!error || undefined}
/>
<Button
type="submit"
className="min-h-11 sm:min-h-9"
disabled={confirm.isPending || code.length !== 6}
>
{confirm.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.totp.verify")}
</Button>
</div>
<button
type="button"
onClick={resetWizard}
className="text-muted-foreground hover:text-foreground text-xs"
>
{t("common.cancel")}
</button>
</form>
</div>
)}
{/* ── Fresh recovery codes (post-confirm or post-regen) ── */}
{freshCodes && (
<div>
<RecoveryCodesPanel codes={freshCodes} />
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setFreshCodes(null)}
>
{t("settings.security.recovery.savedIt")}
</Button>
</div>
</div>
)}
{/* ── Enabled, idle: recovery status + disable/regen actions ── */}
{enabled && !freshCodes && (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t(
recoveryCodesRemaining === 1
? "settings.security.recovery.remainingOne"
: "settings.security.recovery.remainingOther",
{ count: recoveryCodesRemaining },
)}
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
{regenerate.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.recovery.regenerate")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="text-destructive min-h-11 sm:min-h-9"
>
{t("settings.security.totp.disable")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.totp.disableTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.totp.disableConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="disable-code">
{t("settings.security.totp.currentCode")}
</Label>
<Input
id="disable-code"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
inputMode={disableMethod === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
placeholder={
disableMethod === "totp" ? "123456" : "XXXXX-XXXXX"
}
className="font-mono"
/>
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() =>
setDisableMethod((m) =>
m === "totp" ? "recovery" : "totp",
)
}
>
{disableMethod === "totp"
? t("settings.security.useRecoveryCode")
: t("settings.security.useAuthenticator")}
</button>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={disable.isPending || disableCode.length < 6}
aria-busy={disable.isPending || undefined}
onClick={(e) => {
e.preventDefault();
disable.mutate();
}}
>
{disable.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.totp.disable")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{error && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</SettingsCard>
);
}