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 08b3c47876
commit 84b55c0567
6 changed files with 90 additions and 3 deletions

View file

@ -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,