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
|
|
@ -1,16 +1,21 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import OAuthToken, User
|
||||
from app.models import Invite, OAuthToken, User
|
||||
from app.security import encrypt
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only
|
||||
# counterpart. Default login asks for read-only; write is an explicit opt-in via
|
||||
# /auth/upgrade (incremental consent), so friends who won't grant write can still browse.
|
||||
|
|
@ -40,6 +45,41 @@ def has_write_scope(user: User) -> bool:
|
|||
return WRITE_SCOPE in tok.scopes.split()
|
||||
|
||||
|
||||
def is_allowed(db: Session, email: str) -> bool:
|
||||
"""May this email sign in? An approved Invite is the source of truth; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
||||
if not email:
|
||||
return False
|
||||
email = email.lower()
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
if inv is not None:
|
||||
return inv.status == "approved"
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
email = (email or "").lower()
|
||||
if not _EMAIL_RE.match(email):
|
||||
return None
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
if inv is not None:
|
||||
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
||||
if inv.status == "denied":
|
||||
inv.status = "pending"
|
||||
inv.requested_at = datetime.now(timezone.utc)
|
||||
inv.decided_at = None
|
||||
inv.decided_by = None
|
||||
db.commit()
|
||||
return inv
|
||||
return None
|
||||
inv = Invite(email=email, status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
return inv
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
|
|
@ -56,7 +96,11 @@ async def login(request: Request):
|
|||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(request: Request, db: Session = Depends(get_db)):
|
||||
async def callback(
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
|
|
@ -67,11 +111,20 @@ async def callback(request: Request, db: Session = Depends(get_db)):
|
|||
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
||||
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
if not email or email not in settings.allowed_email_set:
|
||||
log.warning("Login denied (not on invite list): %s", email or "<no email>")
|
||||
raise HTTPException(
|
||||
status_code=403, detail="This Google account is not on the invite list."
|
||||
)
|
||||
if not is_allowed(db, email):
|
||||
log.warning("Login denied (not approved): %s", email or "<no email>")
|
||||
# A denied Google login doubles as an access request: record it for the admin and
|
||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||
if email:
|
||||
inv = upsert_pending_invite(db, email)
|
||||
if inv is not None and settings.admin_email_set:
|
||||
background.add_task(
|
||||
email_mod.send_admin_new_request,
|
||||
sorted(settings.admin_email_set),
|
||||
email,
|
||||
)
|
||||
return RedirectResponse(url="/?access=requested")
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
|
||||
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
|
||||
if user is None:
|
||||
|
|
@ -107,6 +160,27 @@ async def logout(request: Request):
|
|||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
payload: dict,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if not _EMAIL_RE.match(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if is_allowed(db, email):
|
||||
return {"status": "approved"} # already allowed — just sign in
|
||||
inv = upsert_pending_invite(db, email)
|
||||
if inv is not None and settings.admin_email_set:
|
||||
background.add_task(
|
||||
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
|
||||
)
|
||||
return {"status": "pending"}
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue