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 d6ca4ccd4e
commit 49ab652692
13 changed files with 605 additions and 15 deletions

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app.config import settings
from app.routes import channels, feed, health, me, sync, tags
from app.routes import admin, channels, feed, health, me, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler
@ -66,6 +66,7 @@ app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(me.router)
app.include_router(channels.router)
app.include_router(admin.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"