diff --git a/messages/de.json b/messages/de.json index c9b7e2fa6..f9333f0c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1930,12 +1930,21 @@ "collapse": "Einklappen", "expand": "Ausklappen", "dangerZone": "Alle Daten löschen", + "dangerZoneTitle": "Gefahrenzone", "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Benutzerkonto bleibt erhalten.", "dangerZoneConfirm": "Alle Daten löschen?", "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Benutzerkonto bleibt erhalten.", "dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht", "dangerZoneDeleteFailed": "Löschen fehlgeschlagen", "finalDelete": "Endgültig löschen", + "deleteAccountCardTitle": "Konto vollständig löschen", + "deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions. Dieser Vorgang kann nicht rückgängig gemacht werden.", + "deleteAccountCta": "Konto löschen", + "deleteAccountConfirmTitle": "Konto endgültig löschen?", + "deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.", + "deleteAccountSuccess": "Konto wurde gelöscht — du wirst abgemeldet.", + "deleteAccountFailed": "Konto konnte nicht gelöscht werden", + "deleteAccountFinal": "Endgültig löschen", "saved": "Gespeichert", "savingError": "Fehler beim Speichern", "moodLogTitle": "moodLog", diff --git a/messages/en.json b/messages/en.json index 30d61380d..450bfd142 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1930,12 +1930,21 @@ "collapse": "Collapse", "expand": "Expand", "dangerZone": "Delete All Data", + "dangerZoneTitle": "Danger zone", "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", "dangerZoneSuccess": "All personal data has been deleted", "dangerZoneDeleteFailed": "Deletion failed", "finalDelete": "Delete permanently", + "deleteAccountCardTitle": "Delete account entirely", + "deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.", + "deleteAccountCta": "Delete account", + "deleteAccountConfirmTitle": "Delete account permanently?", + "deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.", + "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountFailed": "Account could not be deleted", + "deleteAccountFinal": "Delete permanently", "saved": "Saved", "savingError": "Error saving", "moodLogTitle": "moodLog", diff --git a/messages/es.json b/messages/es.json index 56080b14d..44a81af62 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1930,12 +1930,21 @@ "collapse": "Collapse", "expand": "Expand", "dangerZone": "Delete All Data", + "dangerZoneTitle": "Danger zone", "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", "dangerZoneSuccess": "All personal data has been deleted", "dangerZoneDeleteFailed": "Deletion failed", "finalDelete": "Delete permanently", + "deleteAccountCardTitle": "Delete account entirely", + "deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.", + "deleteAccountCta": "Delete account", + "deleteAccountConfirmTitle": "Delete account permanently?", + "deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.", + "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountFailed": "Account could not be deleted", + "deleteAccountFinal": "Delete permanently", "saved": "Guardado", "savingError": "Error saving", "moodLogTitle": "moodLog", diff --git a/messages/fr.json b/messages/fr.json index bca4d77b2..ddeee7003 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1930,12 +1930,21 @@ "collapse": "Collapse", "expand": "Expand", "dangerZone": "Delete All Data", + "dangerZoneTitle": "Danger zone", "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", "dangerZoneSuccess": "All personal data has been deleted", "dangerZoneDeleteFailed": "Deletion failed", "finalDelete": "Delete permanently", + "deleteAccountCardTitle": "Delete account entirely", + "deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.", + "deleteAccountCta": "Delete account", + "deleteAccountConfirmTitle": "Delete account permanently?", + "deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.", + "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountFailed": "Account could not be deleted", + "deleteAccountFinal": "Delete permanently", "saved": "Enregistré", "savingError": "Error saving", "moodLogTitle": "moodLog", diff --git a/messages/it.json b/messages/it.json index a2f6d006a..717188b07 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1930,12 +1930,21 @@ "collapse": "Collapse", "expand": "Expand", "dangerZone": "Delete All Data", + "dangerZoneTitle": "Danger zone", "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", "dangerZoneSuccess": "All personal data has been deleted", "dangerZoneDeleteFailed": "Deletion failed", "finalDelete": "Delete permanently", + "deleteAccountCardTitle": "Delete account entirely", + "deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.", + "deleteAccountCta": "Delete account", + "deleteAccountConfirmTitle": "Delete account permanently?", + "deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.", + "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountFailed": "Account could not be deleted", + "deleteAccountFinal": "Delete permanently", "saved": "Salvato", "savingError": "Error saving", "moodLogTitle": "moodLog", diff --git a/messages/pl.json b/messages/pl.json index 87f33a0f9..937d0ad43 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1930,12 +1930,21 @@ "collapse": "Collapse", "expand": "Expand", "dangerZone": "Delete All Data", + "dangerZoneTitle": "Danger zone", "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", "dangerZoneSuccess": "All personal data has been deleted", "dangerZoneDeleteFailed": "Deletion failed", "finalDelete": "Delete permanently", + "deleteAccountCardTitle": "Delete account entirely", + "deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.", + "deleteAccountCta": "Delete account", + "deleteAccountConfirmTitle": "Delete account permanently?", + "deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.", + "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountFailed": "Account could not be deleted", + "deleteAccountFinal": "Delete permanently", "saved": "Zapisano", "savingError": "Error saving", "moodLogTitle": "moodLog", diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx new file mode 100644 index 000000000..f8387f1fc --- /dev/null +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -0,0 +1,107 @@ +/** + * v1.4.43 QoL (M3 + L5) — `` danger-zone shaping. + * + * M3: a separate "Delete account entirely" destructive card + * (`AccountDeleteCard`) sits beside the existing `DataResetCard` + * so a user reading the GDPR Article 17 surface has the full + * erasure CTA right next to the half-erasure one. + * + * L5: the data-reset card no longer paints a red `AlertTriangle` + * icon and the title sits in the neutral foreground colour + * (mirrors GitHub's danger-zone shaping). The CTA button stays + * red; the protective confirmation dialog stays unchanged. + * + * SSR-only — we render the component statically and inspect the + * markup. The mutation handlers don't fire in SSR, so this test only + * pins the visible structure + copy. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// `` mounts the Research Mode card which calls +// `useQuery` and `useQueryClient` under TanStack Query. The other +// shape tests use the same stub. +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({ + ResearchModeAcknowledgmentDialog: () => ( +
+ ), +})); + +import { AdvancedSection } from "../advanced-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { + it("mounts both the data-reset card and the account-delete card", () => { + const html = render(); + expect(html).toContain('data-slot="settings-data-reset-card"'); + expect(html).toContain('data-slot="settings-account-delete-card"'); + }); + + it("L5: the data-reset card title is neutral foreground (no destructive red)", () => { + const html = render(); + // The reset card's title used to ship with `text-destructive`; the + // v1.4.43 fix pins it on `text-foreground`. We don't care if other + // strings in the section happen to use `text-destructive` (the CTA + // button does), only that the specific card title slot does NOT. + const cardMatch = html.match( + /data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/, + ); + expect(cardMatch).not.toBeNull(); + expect(cardMatch![0]).toContain("text-foreground"); + expect(cardMatch![0]).not.toContain("text-destructive"); + }); + + it("L5: the data-reset card no longer ships an AlertTriangle icon", () => { + const html = render(); + // The icon used to render with a lucide-issued `lucide-alert-triangle` + // class. Pin the absence; if a future revision re-imports the icon + // the SVG class will reappear and this test catches it. + const cardMatch = html.match( + /data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/, + ); + expect(cardMatch).not.toBeNull(); + expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/); + }); + + it("M3: the account-delete card uses the destructive Button variant", () => { + const html = render(); + expect(html).toContain('data-slot="settings-account-delete-trigger"'); + // The trigger button still wears the destructive style — the CTA + // remains red even though the surrounding card chrome is neutral. + const triggerMatch = html.match( + /data-slot="settings-account-delete-trigger"[\s\S]*?<\/button>/, + ); + expect(triggerMatch).not.toBeNull(); + // The destructive button uses the `bg-destructive` class from the + // shadcn variant. + expect(triggerMatch![0]).toContain("bg-destructive"); + }); + + it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => { + const html = render("de"); + expect(html).toContain("Konto vollständig löschen"); + // The descriptive sentence calls out the cascade scope plainly. + expect(html).toContain("Passkeys"); + }); + + it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => { + const html = render("en"); + expect(html).toContain("Delete account entirely"); + expect(html).toContain("passkeys"); + }); +}); diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index c0b9c88e1..f40d4e577 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react"; +import { BookOpenCheck, Loader2, Trash2 } from "lucide-react"; import { AlertDialog, @@ -68,6 +68,7 @@ export function AdvancedSection() { + ); } @@ -266,13 +267,18 @@ function DataResetCard() { } return ( -
-
- -

- {t("settings.dangerZone")} -

-
+
+ {/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and + neutralised the title colour so the danger-zone shaping is + GitHub-style (red CTA only) rather than red-on-red-on-red. + The protective gate (confirmation dialog) is unchanged; this + is purely a visual-tone fix per the v1.4.43 audit. */} +

+ {t("settings.dangerZone")} +

{t("settings.dangerZoneDescription")}

@@ -322,3 +328,122 @@ function DataResetCard() {
); } + +/** + * v1.4.43 QoL (M3) — separate destructive action for the full account + * delete. Pre-fix, the only "danger zone" CTA wiped the user's health + * data but left the user row, passkeys, audit log, and sessions + * intact — half of what a user reading "Danger Zone" expects per + * GDPR Article 17. The route `DELETE /api/settings/account` already + * cascades User + passkeys + audit log + sessions (and is rate-limited + * by the API handler); this card is its UI front door. + * + * Visually the card mirrors the data-reset shaping (neutral title, + * red CTA only) but the dialog copy is explicit: this deletes the + * account, not just the data, and the user will be signed out + * immediately. + */ +function AccountDeleteCard() { + const { t } = useTranslations(); + const [deleting, setDeleting] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + async function handleDeleteAccount() { + setDeleting(true); + setMsg(null); + setMsgType(null); + try { + const res = await fetch("/api/settings/account", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }); + if (!res.ok) { + try { + const json = await res.json(); + setMsg(json.error || t("settings.deleteAccountFailed")); + } catch { + setMsg(t("settings.deleteAccountFailed")); + } + setMsgType("error"); + return; + } + setMsg(t("settings.deleteAccountSuccess")); + setMsgType("success"); + // The route destroyed every session before deleting the row; + // give the toast a beat to paint, then bounce to the login page. + setTimeout(() => { + window.location.href = "/auth/login"; + }, 1_500); + } catch { + setMsg(t("settings.deleteAccountFailed")); + setMsgType("error"); + } finally { + setDeleting(false); + } + } + + return ( +
+

+ {t("settings.deleteAccountCardTitle")} +

+

+ {t("settings.deleteAccountCardDescription")} +

+ +
+ + + + + + + + {t("settings.deleteAccountConfirmTitle")} + + + {t("settings.deleteAccountConfirmDescription")} + + + + {t("common.cancel")} + + {t("settings.deleteAccountFinal")} + + + + +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/tests/integration/settings-account-delete.test.ts b/tests/integration/settings-account-delete.test.ts new file mode 100644 index 000000000..9be695932 --- /dev/null +++ b/tests/integration/settings-account-delete.test.ts @@ -0,0 +1,220 @@ +/** + * v1.4.43 QoL (M3) — integration coverage for + * `DELETE /api/settings/account`. + * + * The audit found the danger-zone UI offered only a half-delete (wipe + * data, keep user row + passkeys + audit log + sessions). The route + * was already in tree; this file pins the contract end-to-end against + * real Postgres: + * + * - happy path cascades User + passkeys + audit log + sessions + * - concurrent sibling sessions are invalidated before the row goes + * - the post-delete cookie can no longer authenticate a read + * - 422 when the confirmation token is missing or wrong + * - 401 when called unauthenticated + * - last-admin guard prevents accidental self-foot-shot + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUserWithSession(opts?: { + role?: "ADMIN" | "USER"; + username?: string; +}): Promise<{ + userId: string; + sessionId: string; +}> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: opts?.username ?? "delete-me", + email: `${opts?.username ?? "delete-me"}@example.test`, + role: opts?.role ?? "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id, sessionId: session.id }; +} + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("DELETE /api/settings/account (real Postgres)", () => { + it("cascades User + passkeys + audit log + sessions", async () => { + const { userId, sessionId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + + // Seed a sibling session, a passkey, and an audit row so we can + // verify every dependency vanishes with the row. + const siblingSession = await prisma.session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + const passkey = await prisma.passkey.create({ + data: { + userId, + name: "iPhone", + credentialId: "cred-1", + credentialPublicKey: Buffer.from("public-key"), + counter: BigInt(0), + credentialDeviceType: "singleDevice", + transports: ["internal"], + }, + }); + // Seed an unrelated audit row to confirm it gets wiped too. + await prisma.auditLog.create({ + data: { + userId, + action: "test.audit.row", + ipAddress: "127.0.0.1", + }, + }); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" })); + expect(res.status).toBe(200); + + // The user row is gone. + const stillThere = await prisma.user.findUnique({ where: { id: userId } }); + expect(stillThere).toBeNull(); + + // Sessions cascaded. + const sessionsAfter = await prisma.session.findMany({ where: { userId } }); + expect(sessionsAfter).toHaveLength(0); + const checkOriginal = await prisma.session.findUnique({ + where: { id: sessionId }, + }); + expect(checkOriginal).toBeNull(); + const checkSibling = await prisma.session.findUnique({ + where: { id: siblingSession.id }, + }); + expect(checkSibling).toBeNull(); + + // Passkey cascaded. + const passkeyAfter = await prisma.passkey.findUnique({ + where: { id: passkey.id }, + }); + expect(passkeyAfter).toBeNull(); + + // Audit-log rows for that user were purged (route deletes them + // explicitly inside the same logical op for GDPR completeness). + const auditRowsAfter = await prisma.auditLog.findMany({ + where: { userId }, + }); + expect(auditRowsAfter).toHaveLength(0); + }); + + it("invalidates a concurrent active session before the row goes", async () => { + const { userId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + + // The "concurrent" session lives in another browser, say. The + // route calls destroyAllSessions first, so that cookie can no + // longer authenticate a subsequent read after the call returns. + const otherSession = await prisma.session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" })); + expect(res.status).toBe(200); + + // Try to look up the concurrent session — it is gone. + const lookup = await prisma.session.findUnique({ + where: { id: otherSession.id }, + }); + expect(lookup).toBeNull(); + }); + + it("returns 422 when the confirmation string is missing", async () => { + await seedUserWithSession(); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({})); + expect(res.status).toBe(422); + }); + + it("returns 422 when the confirmation string is wrong", async () => { + await seedUserWithSession(); + const prisma = getPrismaClient(); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({ confirm: "DELETE" })); + expect(res.status).toBe(422); + + // The user row survived the failed call. + const rows = await prisma.user.count(); + expect(rows).toBe(1); + }); + + it("returns 401 when called without a session cookie", async () => { + cookieJar.clear(); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" })); + expect(res.status).toBe(401); + }); + + it("guards against the last admin deleting themselves", async () => { + // Single-admin instance — the route should refuse the delete and + // surface a 400 instead of orphaning the install. + await seedUserWithSession({ role: "ADMIN", username: "solo-admin" }); + const prisma = getPrismaClient(); + + const { DELETE } = await import("@/app/api/settings/account/route"); + const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" })); + expect(res.status).toBe(400); + const stillThere = await prisma.user.count({ where: { role: "ADMIN" } }); + expect(stillThere).toBe(1); + }); +});