From 17cbe38102768db32a6229e1f7e41b1b493faf21 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 15:46:49 +0200 Subject: [PATCH] feat(account): GDPR self-service account + data deletion (5d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/me.py | 39 +++++++++++++++++++++- frontend/src/components/SettingsPanel.tsx | 34 +++++++++++++++++-- frontend/src/i18n/locales/de/settings.json | 6 ++++ frontend/src/i18n/locales/en/settings.json | 6 ++++ frontend/src/i18n/locales/hu/settings.json | 6 ++++ frontend/src/lib/api.ts | 2 ++ 6 files changed, 90 insertions(+), 3 deletions(-) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index df9e6a5..9631344 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy import func, select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.auth import current_user, has_read_scope, has_write_scope, is_allowed @@ -85,6 +85,43 @@ def get_me( } +@router.delete("/account") +def delete_my_account( + request: Request, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """GDPR self-service erasure: permanently delete the signed-in account and ALL its personal + data — OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications — + which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL). + The shared demo account can't be deleted, and the last remaining admin can't delete itself + (that would lock everyone out of the admin surfaces).""" + if user.is_demo: + raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.") + if user.role == "admin": + admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0 + if admins <= 1: + raise HTTPException( + status_code=400, + detail="You're the only admin — promote another admin before deleting your account.", + ) + email = user.email.lower() + user_id = user.id + # Raw delete so Postgres applies the ON DELETE CASCADE / SET NULL on every dependent table. + db.execute(delete(User).where(User.id == user_id)) + # Erase the access-request/whitelist row for this email too (full erasure; they'd re-request). + db.execute(delete(Invite).where(Invite.email == email)) + db.commit() + # Drop this account from the browser session; switch to another signed-in account if any. + remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id] + if remaining: + request.session["account_ids"] = remaining + request.session["user_id"] = remaining[-1] + else: + request.session.clear() + return {"deleted": True} + + @router.put("/preferences") def update_preferences( preferences: dict, diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 03e108b..954f675 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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 ( <>
@@ -410,6 +427,19 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
)} + + {!me.is_demo && ( +
+

{t("settings.account.deleteHint")}

+ +
+ )} ); } diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index b610ea8..24d1ab0 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -55,6 +55,12 @@ "enable": "Aktivieren", "enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.", "walkMeThrough": "Führe mich durch die Einrichtung", + "dangerZone": "Gefahrenzone", + "deleteHint": "Löscht dein Konto und alle deine Daten dauerhaft — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen. Kann nicht rückgängig gemacht werden.", + "deleteAccount": "Mein Konto löschen", + "deleteTitle": "Konto löschen?", + "deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.", + "deleteConfirmButton": "Alles löschen", "demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto." }, "demo": { diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 7302b80..e679450 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -55,6 +55,12 @@ "enable": "Enable", "enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.", "walkMeThrough": "Walk me through setup", + "dangerZone": "Danger zone", + "deleteHint": "Permanently delete your account and all your data — subscriptions, tags, watch history, playlists and settings. This can't be undone.", + "deleteAccount": "Delete my account", + "deleteTitle": "Delete your account?", + "deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.", + "deleteConfirmButton": "Delete everything", "demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account." }, "demo": { diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index d06369f..5e682ae 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -55,6 +55,12 @@ "enable": "Engedélyezés", "enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.", "walkMeThrough": "Vezess végig a beállításon", + "dangerZone": "Veszélyzóna", + "deleteHint": "Véglegesen törli a fiókodat és minden adatodat — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások. Nem vonható vissza.", + "deleteAccount": "Fiókom törlése", + "deleteTitle": "Törlöd a fiókodat?", + "deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.", + "deleteConfirmButton": "Minden törlése", "demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz." }, "demo": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2cf3470..313c4ea 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -480,6 +480,8 @@ export interface AdminUserRow { export const api = { me: (): Promise => req("/api/me"), accounts: (): Promise => req("/api/me/accounts"), + deleteAccount: (): Promise<{ deleted: boolean }> => + req("/api/me/account", { method: "DELETE" }), switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), version: (): Promise => req("/api/version"),