feat(m5c): onboarding — DB invites, request-access, admin approval, email

Move the access whitelist from the ALLOWED_EMAILS env var into a DB Invite table
(env kept as bootstrap fallback), and add a self-service request + admin approval
flow with fail-soft email.

- models: Invite(email, status pending|approved|denied, requested_at, decided_*)
- migration 0008: invites table; seed env ALLOWED_EMAILS u ADMIN_EMAILS as approved
- auth: is_allowed() (DB-first, env fallback); a denied Google login records a pending
  request and bounces to /?access=requested instead of a raw 403; public POST
  /auth/request-access; upsert is idempotent so repeats don't re-spam admins
- routes/admin.py (admin-only): list/approve/deny invites + manual add
- email.py: smtplib + Gmail App Password, fail-soft (skips if SMTP unset)
- /api/me exposes pending_invites; config + .env.example gain SMTP_*
- UI: Login 'Request access' form + access=requested/denied handling; Settings ->
  Access requests (approve/deny + add); admin nudge toast on pending requests

Verified locally: request-access creates a pending invite and emails the admin;
seed approved npeter83; guinea-pig yt.trash2023 denied until approved.
This commit is contained in:
npeter83 2026-06-12 01:43:07 +02:00
parent a4b1ea5c19
commit d9caf12202
13 changed files with 605 additions and 15 deletions

100
backend/app/routes/admin.py Normal file
View file

@ -0,0 +1,100 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add."""
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app.auth import current_user
from app.db import get_db
from app.models import Invite, User
router = APIRouter(prefix="/api/admin", tags=["admin"])
def admin_user(user: User = Depends(current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return user
def _serialize(inv: Invite) -> dict:
return {
"id": inv.id,
"email": inv.email,
"status": inv.status,
"note": inv.note,
"requested_at": inv.requested_at.isoformat() if inv.requested_at else None,
"decided_at": inv.decided_at.isoformat() if inv.decided_at else None,
"decided_by": inv.decided_by,
}
@router.get("/invites")
def list_invites(
status: str | None = None,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> list[dict]:
stmt = select(Invite)
if status:
stmt = stmt.where(Invite.status == status)
# Pending first, then most-recently requested.
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
order = {"pending": 0, "approved": 1, "denied": 2}
rows.sort(key=lambda i: order.get(i.status, 9))
return [_serialize(i) for i in rows]
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
db.commit()
return inv
@router.post("/invites/{invite_id}/approve")
def approve_invite(
invite_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv)
@router.post("/invites/{invite_id}/deny")
def deny_invite(
invite_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
return _serialize(_decide(db, invite_id, admin, approved=False))
@router.post("/invites")
def add_invite(
payload: dict,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
email = (payload.get("email") or "").strip().lower()
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is None:
inv = Invite(email=email)
db.add(inv)
inv.status = "approved"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
db.commit()
return _serialize(inv)

View file

@ -1,15 +1,28 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import current_user, has_write_scope
from app.db import get_db
from app.models import User
from app.models import Invite, User
router = APIRouter(prefix="/api/me", tags=["me"])
@router.get("")
def get_me(user: User = Depends(current_user)) -> dict:
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,
@ -17,6 +30,7 @@ def get_me(user: User = Depends(current_user)) -> dict:
"avatar_url": user.avatar_url,
"role": user.role,
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
"preferences": user.preferences or {},
}