merge: integrate the v1.23 security settings and MFA wave

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/openapi/routes/auth.ts
#	src/lib/query-keys/auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:03 +02:00
commit c458d955e4
45 files changed
+4211 -551

No files matched your search

@@ -0,0 +1,54 @@
/**
* MfaLoginStep rendering — the second step of login shown when the password
* response carried `meta.mfaRequired`. Pins which controls render for each
* factor mix (TOTP+recovery, TOTP+webauthn, webauthn-only).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
import { MfaLoginStep } from "../mfa-login-step";
function render(methods: ("totp" | "recovery" | "webauthn")[]) {
return renderToStaticMarkup(
<MfaLoginStep
mfaTicket="ticket-1"
methods={methods}
onSuccess={() => {}}
onCancel={() => {}}
/>,
);
}
describe("MfaLoginStep", () => {
it("renders the code entry + recovery toggle for a TOTP account", () => {
const html = render(["totp", "recovery"]);
expect(html).toContain("mfa-login-step");
expect(html).toContain("auth.mfa.codeLabel");
expect(html).toContain("auth.mfa.verify");
expect(html).toContain("auth.mfa.useRecoveryCode");
// No security-key button when the account has no registered key.
expect(html).not.toContain("auth.mfa.useSecurityKey");
});
it("renders the security-key button alongside TOTP when both are available", () => {
const html = render(["totp", "recovery", "webauthn"]);
expect(html).toContain("auth.mfa.codeLabel");
expect(html).toContain("auth.mfa.useSecurityKey");
});
it("leads with the security key for a WebAuthn-only account", () => {
const html = render(["webauthn"]);
expect(html).toContain("auth.mfa.useSecurityKey");
// No code form and no recovery toggle without TOTP.
expect(html).not.toContain("auth.mfa.codeLabel");
expect(html).not.toContain("auth.mfa.useRecoveryCode");
});
it("always offers a way back to the password step", () => {
expect(render(["totp"])).toContain("auth.mfa.backToLogin");
});
});
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useId, useState } from "react";
import { KeyRound, Loader2, Lock, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type MfaMethod = "totp" | "recovery" | "webauthn";
/**
* Second step of login when the password response carried `meta.mfaRequired`.
*
* Renders the TOTP code entry by default, with a switch to a recovery code and
* — when the account has a registered security key — a "use a security key"
* button that runs the scoped WebAuthn assertion. Each path posts back to the
* matching verify endpoint and, on success, calls `onSuccess` (the parent
* finishes the login exactly like a normal sign-in).
*/
export function MfaLoginStep({
mfaTicket,
methods,
onSuccess,
onCancel,
}: {
mfaTicket: string;
methods: MfaMethod[];
onSuccess: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
const codeFieldId = useId();
const errorId = useId();
const hasTotp = methods.includes("totp");
const hasWebauthn = methods.includes("webauthn");
// Default to a code entry when TOTP is available; otherwise lead with the
// security-key path (a WebAuthn-only account).
const [useRecovery, setUseRecovery] = useState(false);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await apiPost("/api/auth/mfa/verify", {
mfaTicket,
method: useRecovery ? "recovery" : "totp",
code: code.trim(),
});
onSuccess();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
}
}
async function submitSecurityKey() {
setError(null);
setLoading(true);
try {
const { startAuthentication } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/mfa/webauthn/verify/options", { mfaTicket });
const credential = await startAuthentication({ optionsJSON: options });
await apiPost("/api/auth/mfa/webauthn/verify", {
mfaTicket,
challengeId,
credential,
});
onSuccess();
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || t("auth.mfa.verifyFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
}
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4" data-testid="mfa-login-step">
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<ShieldCheck className="text-primary h-5 w-5" />
</div>
<h2 className="text-lg font-semibold">{t("auth.mfa.title")}</h2>
<p className="text-muted-foreground text-sm">
{useRecovery
? t("auth.mfa.recoveryHint")
: hasTotp
? t("auth.mfa.totpHint")
: t("auth.mfa.securityKeyHint")}
</p>
</div>
{(hasTotp || useRecovery) && (
<form onSubmit={submitCode} className="space-y-3">
<div className="space-y-2">
<Label htmlFor={codeFieldId}>
{useRecovery
? t("auth.mfa.recoveryLabel")
: t("auth.mfa.codeLabel")}
</Label>
<Input
id={codeFieldId}
value={code}
onChange={(e) =>
setCode(
useRecovery
? e.target.value
: e.target.value.replace(/\D/g, "").slice(0, 6),
)
}
inputMode={useRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? "XXXXX-XXXXX" : "123456"}
className="font-mono tracking-widest"
aria-invalid={!!error || undefined}
aria-describedby={error ? errorId : undefined}
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading || code.trim().length < 6}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.mfa.verify")}
</Button>
</form>
)}
{hasWebauthn && (
<Button
type="button"
variant={hasTotp ? "outline" : "default"}
className="min-h-11 w-full"
size="lg"
onClick={submitSecurityKey}
disabled={loading}
>
<KeyRound className="h-4 w-4" />
{t("auth.mfa.useSecurityKey")}
</Button>
)}
{hasTotp && (
<button
type="button"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");
setError(null);
}}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{useRecovery
? t("auth.mfa.useAuthenticator")
: t("auth.mfa.useRecoveryCode")}
</button>
)}
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
<button
type="button"
onClick={onCancel}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.mfa.backToLogin")}
</button>
</div>
);
}
@@ -99,6 +99,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// health record belongs next to the health-record export.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"security",
"modules",
"integrations",
"channels",
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { KeyRound, Loader2, Save, Shield, User } from "lucide-react";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -21,7 +21,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -37,7 +36,6 @@ import {
statusText,
type StatusMessage,
} from "./account-section-utils";
import { PasskeyListSection } from "./passkey-list-section";
import { AvatarSection } from "./avatar-section";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
@@ -79,12 +77,8 @@ export function AccountSection() {
>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyMsg, setPasskeyMsg] = useState<StatusMessage | null>(null);
const [passkeyMsgType, setPasskeyMsgType] = useState<
"success" | "error" | null
>(null);
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
// Auth gate — push back to login if the user is unauthenticated. Effect
// intentionally only navigates (never sets state in a way that re-runs the
@@ -179,56 +173,6 @@ export function AccountSection() {
setSaving(false);
}
async function handleAddPasskey() {
setPasskeyLoading(true);
setPasskeyMsg(null);
setPasskeyMsgType(null);
try {
const optRes = await apiFetchRaw("/api/auth/passkey/register-options", {
method: "POST",
});
if (!optRes.ok) {
setPasskeyMsg({ key: "settings.passkeyOptionsError" });
setPasskeyMsgType("error");
setPasskeyLoading(false);
return;
}
const optJson = await optRes.json();
const { options, challengeId } = optJson.data;
const { startRegistration } = await import("@simplewebauthn/browser");
const credential = await startRegistration({ optionsJSON: options });
const verifyRes = await apiFetchRaw("/api/auth/passkey/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeId, credential }),
});
if (verifyRes.ok) {
setPasskeyMsg({ key: "settings.passkeyAdded" });
setPasskeyMsgType("success");
} else {
const verifyJson = await verifyRes.json();
setPasskeyMsg(
verifyJson.error
? { text: verifyJson.error }
: { key: "settings.passkeyRegistrationFailed" },
);
setPasskeyMsgType("error");
}
} catch (err) {
const { key, params } = describePasskeyError(err);
setPasskeyMsg({ key, params });
setPasskeyMsgType("error");
} finally {
setPasskeyLoading(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordSaving(true);
@@ -521,43 +465,6 @@ export function AccountSection() {
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passkeys")}
className="mb-4"
/>
<div className="pl-7">
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
<div className="mt-4 flex justify-end pl-7">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={handleAddPasskey}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
</div>
{passkeyMsg && (
<p
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(passkeyMsg, t)}
</p>
)}
</SettingsCard>
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`<sm`) the action button stacks below the title +
description and renders full-width; on desktop (`>=sm`) it
@@ -1,282 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
}
export function PasskeyListSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => {
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
},
enabled: isAuthenticated,
});
const deletePasskey = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
setDeleteMsg(null);
},
onError: (err: Error) => {
setDeleteMsg(err.message);
},
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
</div>
);
}
return (
<div>
<h3 className="text-sm font-semibold">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.passkeysDescription")}
</p>
{/* Phase A5 / B-mobile HIGH: the passkey table previously
rendered with `min-w-[620px]` inside `overflow-x-auto`,
which on a 393px viewport hid the right-most column —
including the destructive delete action. Render the desktop
table only at `≥ md`, and at `< md` paint a card-list where
every passkey's name, device type, backup status, created
date, and delete action are all visible without scrolling. */}
{/* v1.4.33 — desktop table flips to card list at `lg` instead
of `md` so iPad portrait (768 px = exactly the `md`
inflection) lands on the card layout. The table needs
~620 px of column width to read; below that it scrolls
horizontally and the destructive delete column is the one
that disappears. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyDevice")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyBackup")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.passkeyActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{passkeys.map((pk, idx) => (
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
<td className="px-3 py-2 font-medium">{pk.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</td>
<td className="px-3 py-2 text-xs">
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-xs"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(pk.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-3.5 w-3.5" />
</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={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile + tablet card list — < lg only. Each passkey gets
its own card so the delete action stays visible and
tap-targetable (44 px) without the user having to
horizontally scroll through a wide table. v1.4.33 bumped
the breakpoint from `md` to `lg` so iPad portrait (768 px)
stays on the card layout instead of flipping between
layouts on rotation. */}
<ul
className="mt-3 space-y-2 lg:hidden"
data-testid="passkeys-mobile-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">
<p className="truncate text-sm font-medium">{pk.name}</p>
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
plain text to an outline Badge so the mobile card
list reads consistent with the desktop table's
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
<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">
{formatDate(pk.createdAt)}
</span>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11"
disabled={deletePasskey.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={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.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>
</SettingsCard>
))}
</ul>
{deleteMsg && (
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
)}
</div>
);
}
@@ -28,6 +28,7 @@ import {
Radio,
Settings2,
Share2,
ShieldCheck,
SlidersHorizontal,
Smile,
Sparkles,
@@ -43,6 +44,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
security: ShieldCheck,
modules: Blocks,
integrations: Link2,
channels: Radio,
+4
View File
@@ -49,6 +49,10 @@
// its own top-level entry.
export const SETTINGS_SECTION_SLUGS = [
"account",
// v1.23 — `security` is the dedicated account-security home: second-factor
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
// right after `account` so the two identity surfaces read as neighbours.
"security",
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
// single front door for enabling/disabling secondary tracking domains.
"modules",
@@ -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>
);
}
@@ -34,6 +34,7 @@ import {
Radio,
Settings2,
Share2,
ShieldCheck,
SlidersHorizontal,
Smile,
Sparkles,
@@ -99,6 +100,13 @@ interface SettingsSection {
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
// v1.23 — the account-security home: second factors, recovery codes,
// passkey management. Sits directly under Account.
{
slug: "security",
titleKey: "settings.sections.security.title",
icon: ShieldCheck,
},
// v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one
// place to enable/disable the secondary tracking domains.
{