feat(account): GDPR self-service account + data deletion (5d)

Add DELETE /api/me/account: permanently erases the signed-in account and all its
personal data — OAuth tokens, subscriptions, tags, video states, playlists,
notifications all cascade on the users row (FK ON DELETE CASCADE); quota-audit
events are kept but anonymised (SET NULL); the email's invite/whitelist row is
removed too. Guards: the shared demo account can't be deleted, and the last
remaining admin can't delete itself. Frontend: a Danger zone in Settings -> Account
(non-demo) with a danger-confirm; on success the session clears and the app lands
on the welcome page. EN/HU/DE. Verified via curl: self-delete + session clear +
demo 403.
This commit is contained in:
npeter83 2026-06-19 15:46:49 +02:00
parent 68af2c8bdd
commit 17cbe38102
6 changed files with 90 additions and 3 deletions

View file

@ -1,11 +1,12 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Bell, Check, Monitor, RotateCcw, Save, User } from "lucide-react";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { type Me } from "../lib/api";
import { api, type Me } from "../lib/api";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
@ -354,6 +355,22 @@ function AccessRow({
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
const confirm = useConfirm();
const onDeleteAccount = async () => {
const ok = await confirm({
title: t("settings.account.deleteTitle"),
message: t("settings.account.deleteConfirm"),
confirmLabel: t("settings.account.deleteConfirmButton"),
danger: true,
});
if (!ok) return;
try {
await api.deleteAccount();
window.location.reload(); // session cleared server-side → lands on the welcome page
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
}
};
return (
<>
<Section title={t("settings.account.title")}>
@ -410,6 +427,19 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</button>
</Section>
)}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.account.deleteHint")}</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-red-500/40 text-red-400 hover:bg-red-500/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Section>
)}
</>
);
}