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
58
backend/alembic/versions/0008_invites.py
Normal file
58
backend/alembic/versions/0008_invites.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""invites: DB-backed access whitelist / request queue
|
||||
|
||||
Revision ID: 0008_invites
|
||||
Revises: 0007_deep_requested
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.config import settings
|
||||
|
||||
revision: str = "0008_invites"
|
||||
down_revision: Union[str, None] = "0007_deep_requested"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"invites",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("email", sa.String(length=320), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), nullable=False, server_default="pending"
|
||||
),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"requested_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_by", sa.String(length=320), nullable=True),
|
||||
)
|
||||
op.create_index("ix_invites_email", "invites", ["email"], unique=True)
|
||||
|
||||
# Seed the current env-based whitelist as already-approved invites so existing access
|
||||
# is preserved and shows up in the admin UI. Idempotent on re-run via the unique email.
|
||||
seed = sorted(settings.allowed_email_set | settings.admin_email_set)
|
||||
if seed:
|
||||
invites = sa.table(
|
||||
"invites",
|
||||
sa.column("email", sa.String),
|
||||
sa.column("status", sa.String),
|
||||
sa.column("decided_by", sa.String),
|
||||
)
|
||||
op.bulk_insert(
|
||||
invites,
|
||||
[{"email": e, "status": "approved", "decided_by": "seed"} for e in seed],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_invites_email", table_name="invites")
|
||||
op.drop_table("invites")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ class Settings(BaseSettings):
|
|||
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||
frontend_origin: str = ""
|
||||
|
||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
||||
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
|
||||
# (fail-soft) and onboarding still works via in-app notifications.
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "" # e.g. "Subfeed <addr@gmail.com>"; falls back to smtp_user
|
||||
|
||||
# --- Sync / YouTube Data API ---
|
||||
# Optional API key for public reads (channels/videos/playlistItems). When set it is
|
||||
# preferred for shared backfill/enrichment so it doesn't depend on a specific user.
|
||||
|
|
|
|||
60
backend/app/email.py
Normal file
60
backend/app/email.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Outbound email for onboarding (access requests + approval notices).
|
||||
|
||||
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
|
||||
or a send errors, we log and return False so the caller can fall back to in-app
|
||||
notifications — email is never load-bearing for the app's core flow.
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
log = logging.getLogger("subfeed.email")
|
||||
|
||||
|
||||
def email_enabled() -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password)
|
||||
|
||||
|
||||
def _send(to: list[str], subject: str, body: str) -> bool:
|
||||
recipients = [e for e in to if e]
|
||||
if not recipients:
|
||||
return False
|
||||
if not email_enabled():
|
||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from or settings.smtp_user
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg.set_content(body)
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s:
|
||||
s.starttls(context=ssl.create_default_context())
|
||||
s.login(settings.smtp_user, settings.smtp_password)
|
||||
s.send_message(msg)
|
||||
log.info("Sent email %r to %s", subject, recipients)
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
return _send(
|
||||
[email],
|
||||
"Your Subfeed access is approved",
|
||||
"Good news — your access to Subfeed has been approved.\n\n"
|
||||
"Sign in with this Google account to start browsing your subscriptions.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
return _send(
|
||||
admins,
|
||||
"Subfeed: new access request",
|
||||
f"{requester} has requested access to Subfeed.\n\n"
|
||||
"Approve or deny it in Settings → Admin.\n",
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,26 @@ class OAuthToken(Base):
|
|||
user: Mapped["User"] = relationship(back_populates="token")
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||
|
||||
__tablename__ = "invites"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# pending | approved | denied
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), default="pending", server_default="pending"
|
||||
)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
requested_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
|
||||
|
||||
class Channel(Base):
|
||||
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
||||
for everyone), so its expensive metadata is fetched and stored only once."""
|
||||
|
|
|
|||
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