from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import ( current_user, google_enabled, has_read_scope, has_write_scope, is_allowed, purge_user, ) from app.db import get_db from app.models import Invite, User router = APIRouter(prefix="/api/me", tags=["me"]) @router.get("/accounts") def my_accounts( request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> list[dict]: """The accounts that have authenticated in this browser session (switchable without a new Google sign-in). The active one is flagged.""" ids = request.session.get("account_ids") or [user.id] rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()} out = [] for uid in ids: # preserve recency order from the session u = rows.get(uid) if u is not None: out.append( { "id": u.id, "email": u.email, "display_name": u.display_name, "avatar_url": u.avatar_url, "active": u.id == user.id, } ) return out @router.post("/switch") def switch_account( payload: dict, request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Switch the active account to another one already authenticated in this browser. No Google round-trip — but only accounts present in the session list (proof they signed in here) are allowed, and access is re-checked in case the invite was revoked since.""" target = payload.get("user_id") if target not in (request.session.get("account_ids") or []): raise HTTPException(status_code=403, detail="Not an account you've signed into here.") u = db.get(User, target) if u is None: raise HTTPException(status_code=404, detail="That account no longer exists.") if not is_allowed(db, u.email): raise HTTPException(status_code=403, detail="That account no longer has access.") request.session["user_id"] = target return {"ok": True, "user_id": target} @router.get("") def get_me( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: pending_invites = 0 if user.role == "admin": pending_invites = ( db.scalar( select(func.count()) .select_from(Invite) .where(Invite.status == "pending") ) or 0 ) return { "id": user.id, "email": user.email, "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, "is_demo": user.is_demo, "has_google": user.google_sub is not None, "has_password": user.password_hash is not None, # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # YouTube-access affordances and the onboarding nudge when it isn't configured). "google_enabled": google_enabled(db), "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, "preferences": user.preferences or {}, } @router.delete("/account") def delete_my_account( request: Request, background: BackgroundTasks, 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.", ) user_id = user.id # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. purge_user(db, user, background) # 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, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: # Merge so partial updates don't wipe other keys. merged = dict(user.preferences or {}) merged.update(preferences) user.preferences = merged db.add(user) db.commit() return {"preferences": merged}