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 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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -480,6 +480,8 @@ export interface AdminUserRow {
|
|||
export const api = {
|
||||
me: (): Promise<Me> => req("/api/me"),
|
||||
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 }> =>
|
||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
||||
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue