mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(settings): add account-delete CTA and quieten danger-zone visuals
This commit is contained in:
1 parent
b2e2f78ac2
commit
a911b335a0
9 files changed
+514
-8
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user