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"),