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:
parent
72cd8e6d65
commit
8c727dd99e
9 changed files with 507 additions and 43 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue