feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing

- Link a Google account to a password account, and adopt the Google identity onto a matching
  email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
  login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
  'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
  revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
This commit is contained in:
npeter83 2026-06-19 19:52:02 +02:00
parent c0dde06920
commit 2aa13a6433
9 changed files with 507 additions and 43 deletions

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import delete, func, select
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import func, select
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, purge_user
from app.db import get_db
from app.models import Invite, User
@ -78,6 +78,8 @@ def get_me(
"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,
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
@ -88,6 +90,7 @@ def get_me(
@router.delete("/account")
def delete_my_account(
request: Request,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@ -105,13 +108,10 @@ def delete_my_account(
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()
# 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: