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:
parent
d6ca4ccd4e
commit
49ab652692
13 changed files with 605 additions and 15 deletions
100
backend/app/routes/admin.py
Normal file
100
backend/app/routes/admin.py
Normal 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)
|
||||
|
|
@ -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 {},
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue