mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user