Merge feature/auth-5d-gdpr (GDPR account + data deletion)
Self-service DELETE /api/me/account (cascades all personal data; demo + last-admin guarded) and a Danger zone in Settings -> Account.
This commit is contained in:
commit
105505f67e
6 changed files with 90 additions and 3 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, has_read_scope, has_write_scope, is_allowed
|
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")
|
@router.put("/preferences")
|
||||||
def update_preferences(
|
def update_preferences(
|
||||||
preferences: dict,
|
preferences: dict,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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 Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
// 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).
|
// 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 }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title={t("settings.account.title")}>
|
<Section title={t("settings.account.title")}>
|
||||||
|
|
@ -410,6 +427,19 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@
|
||||||
"enable": "Aktivieren",
|
"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.",
|
"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",
|
"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."
|
"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": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
|
"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",
|
"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."
|
"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": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@
|
||||||
"enable": "Engedélyezés",
|
"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.",
|
"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",
|
"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."
|
"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": {
|
"demo": {
|
||||||
|
|
|
||||||
|
|
@ -480,6 +480,8 @@ export interface AdminUserRow {
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
|
deleteAccount: (): Promise<{ deleted: boolean }> =>
|
||||||
|
req("/api/me/account", { method: "DELETE" }),
|
||||||
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
||||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
||||||
version: (): Promise<VersionInfo> => req("/api/version"),
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue