feat(settings): add account-delete CTA and quieten danger-zone visuals

This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:32:02 +02:00
1 parent b2e2f78ac2
commit a911b335a0
9 files changed
+514 -8

No files matched your search

+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
@@ -0,0 +1,107 @@
/**
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` 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";
// `<AdvancedSection>` 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: () => (
<div data-slot="mock-ack-dialog" />
),
}));
import { AdvancedSection } from "../advanced-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AdvancedSection />
</I18nProvider>,
);
}
describe("<AdvancedSection> 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");
});
});
+133 -8
View File
@@ -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() {
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</section>
);
}
@@ -266,13 +267,18 @@ function DataResetCard() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<h2 className="text-destructive text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
</div>
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-data-reset-card"
>
{/* 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. */}
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
@@ -322,3 +328,122 @@ function DataResetCard() {
</div>
);
}
/**
* 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<string | null>(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 (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-account-delete-card"
>
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
<div className="mt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteAccount}
data-slot="settings-account-delete-confirm"
>
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}
@@ -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);
});
});