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
5754f47f20
commit
2add173760
13 changed files with 605 additions and 15 deletions
10
.env.example
10
.env.example
|
|
@ -36,6 +36,16 @@ FRONTEND_ORIGIN=
|
||||||
# screen is in "Testing"). Strongly recommended for the always-on server instance.
|
# screen is in "Testing"). Strongly recommended for the always-on server instance.
|
||||||
YOUTUBE_API_KEY=
|
YOUTUBE_API_KEY=
|
||||||
|
|
||||||
|
# Optional: outbound email for onboarding (access-request + approval notices). Gmail SMTP +
|
||||||
|
# App Password (account needs 2FA; generate at https://myaccount.google.com/apppasswords under
|
||||||
|
# the SENDING account). All optional — if unset, email is skipped and onboarding still works
|
||||||
|
# via in-app notifications. SMTP_FROM falls back to SMTP_USER.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
# --- Deployment role ---
|
# --- Deployment role ---
|
||||||
# DATABASE_URL is set automatically by docker-compose.yml / docker-compose.server.yml to the
|
# DATABASE_URL is set automatically by docker-compose.yml / docker-compose.server.yml to the
|
||||||
# bundled `db` service. Override it only for local dev against the central server DB, e.g.:
|
# bundled `db` service. Override it only for local dev against the central server DB, e.g.:
|
||||||
|
|
|
||||||
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 logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
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 fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import email as email_mod
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import OAuthToken, User
|
from app.models import Invite, OAuthToken, User
|
||||||
from app.security import encrypt
|
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
|
# 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
|
# 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.
|
# /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()
|
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")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||||
|
|
@ -56,7 +96,11 @@ async def login(request: Request):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
@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:
|
try:
|
||||||
token = await oauth.google.authorize_access_token(request)
|
token = await oauth.google.authorize_access_token(request)
|
||||||
except OAuthError as exc:
|
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")
|
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
||||||
|
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
if not email or email not in settings.allowed_email_set:
|
if not is_allowed(db, email):
|
||||||
log.warning("Login denied (not on invite list): %s", email or "<no email>")
|
log.warning("Login denied (not approved): %s", email or "<no email>")
|
||||||
raise HTTPException(
|
# A denied Google login doubles as an access request: record it for the admin and
|
||||||
status_code=403, detail="This Google account is not on the invite list."
|
# 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()
|
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -107,6 +160,27 @@ async def logout(request: Request):
|
||||||
return JSONResponse({"ok": True})
|
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:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
if not 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.
|
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||||
frontend_origin: str = ""
|
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 ---
|
# --- Sync / YouTube Data API ---
|
||||||
# Optional API key for public reads (channels/videos/playlistItems). When set it is
|
# 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.
|
# 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 import auth
|
||||||
from app.config import settings
|
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
|
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(feed.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,26 @@ class OAuthToken(Base):
|
||||||
user: Mapped["User"] = relationship(back_populates="token")
|
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):
|
class Channel(Base):
|
||||||
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
"""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."""
|
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 fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, has_write_scope
|
from app.auth import current_user, has_write_scope
|
||||||
from app.db import get_db
|
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 = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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 {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
|
@ -17,6 +30,7 @@ def get_me(user: User = Depends(current_user)) -> dict:
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
|
"pending_invites": pending_invites,
|
||||||
"preferences": user.preferences or {},
|
"preferences": user.preferences or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
saveLayoutLocal,
|
saveLayoutLocal,
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
} from "./lib/sidebarLayout";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications } from "./lib/notifications";
|
import { configureNotifications, notify } from "./lib/notifications";
|
||||||
import { setHintsEnabled } from "./lib/hints";
|
import { setHintsEnabled } from "./lib/hints";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
|
|
@ -104,6 +104,15 @@ export default function App() {
|
||||||
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||||
if (typeof prefs.performanceMode === "boolean")
|
if (typeof prefs.performanceMode === "boolean")
|
||||||
document.documentElement.dataset.perf = prefs.performanceMode ? "1" : "";
|
document.documentElement.dataset.perf = prefs.performanceMode ? "1" : "";
|
||||||
|
// Nudge admins when access requests are waiting (in lieu of a server-push bell).
|
||||||
|
const pending = meQuery.data?.pending_invites ?? 0;
|
||||||
|
if (meQuery.data?.role === "admin" && pending > 0) {
|
||||||
|
notify({
|
||||||
|
level: "warning",
|
||||||
|
title: "Access requests",
|
||||||
|
message: `${pending} pending — review in Settings → Account.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,33 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
// A denied Google login bounces back here with ?access=requested (we recorded it) or
|
||||||
|
// ?access=denied (no usable email). Surface that so the user isn't left on a raw error.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const bounced = params.get("access"); // "requested" | "denied" | null
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setPhase("sending");
|
||||||
|
try {
|
||||||
|
const r = await api.requestAccess(email.trim());
|
||||||
|
setPhase(r.status === "approved" ? "approved" : "requested");
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't submit — check the address and try again.");
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = phase === "requested" || phase === "approved";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
||||||
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
|
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
|
||||||
|
|
@ -16,7 +45,47 @@ export default function Login() {
|
||||||
>
|
>
|
||||||
Sign in with Google
|
Sign in with Google
|
||||||
</a>
|
</a>
|
||||||
<div className="text-xs text-muted mt-6">Invite-only access.</div>
|
|
||||||
|
<div className="mt-7 pt-6 border-t border-border text-left">
|
||||||
|
{phase === "approved" ? (
|
||||||
|
<p className="text-sm text-fg/80">
|
||||||
|
You're already approved — just sign in with Google above.
|
||||||
|
</p>
|
||||||
|
) : done ? (
|
||||||
|
<p className="text-sm text-fg/80">
|
||||||
|
Thanks — your request is in. We'll email you when it's approved.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
|
No access yet?
|
||||||
|
</div>
|
||||||
|
{bounced === "denied" && (
|
||||||
|
<p className="text-xs text-muted mb-2">
|
||||||
|
That Google account isn't approved. Request access below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={submit} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@gmail.com"
|
||||||
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={phase === "sending"}
|
||||||
|
className="shrink-0 px-3 py-2 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{phase === "sending" ? "Sending…" : "Request access"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, History, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
|
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Invite, type Me } from "../lib/api";
|
||||||
import { formatEta } from "../lib/format";
|
import { formatEta } from "../lib/format";
|
||||||
import {
|
import {
|
||||||
configureNotifications,
|
configureNotifications,
|
||||||
|
|
@ -434,6 +434,7 @@ function Sync({ me }: { me: Me }) {
|
||||||
|
|
||||||
function Account({ me }: { me: Me }) {
|
function Account({ me }: { me: Me }) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Section title="Account">
|
<Section title="Account">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{me.avatar_url ? (
|
{me.avatar_url ? (
|
||||||
|
|
@ -478,5 +479,148 @@ function Account({ me }: { me: Me }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
{me.role === "admin" && <AdminInvites />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminInvites() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin-invites"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
|
||||||
|
};
|
||||||
|
const approve = useMutation({
|
||||||
|
mutationFn: (id: number) => api.approveInvite(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
refresh();
|
||||||
|
notify({ level: "success", message: "Approved — they can sign in now" });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: "Approve failed" }),
|
||||||
|
});
|
||||||
|
const deny = useMutation({
|
||||||
|
mutationFn: (id: number) => api.denyInvite(id),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: () => notify({ level: "error", message: "Deny failed" }),
|
||||||
|
});
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (email: string) => api.addInvite(email),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewEmail("");
|
||||||
|
refresh();
|
||||||
|
notify({ level: "success", message: "Added to the whitelist" });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: "Couldn't add that email" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = invites.data ?? [];
|
||||||
|
const pending = list.filter((i) => i.status === "pending");
|
||||||
|
const decided = list.filter((i) => i.status !== "pending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Access requests">
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No pending requests.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{pending.map((i) => (
|
||||||
|
<InviteRow
|
||||||
|
key={i.id}
|
||||||
|
inv={i}
|
||||||
|
onApprove={() => approve.mutate(i.id)}
|
||||||
|
onDeny={() => deny.mutate(i.id)}
|
||||||
|
busy={approve.isPending || deny.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 mt-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
placeholder="Add an email directly…"
|
||||||
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" /> Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{decided.length > 0 && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="text-xs text-muted cursor-pointer">
|
||||||
|
{decided.length} decided
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
|
{decided.map((i) => (
|
||||||
|
<div key={i.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{i.email}</span>
|
||||||
|
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
|
||||||
|
{i.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteRow({
|
||||||
|
inv,
|
||||||
|
onApprove,
|
||||||
|
onDeny,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
inv: Invite;
|
||||||
|
onApprove: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">{inv.email}</div>
|
||||||
|
{inv.requested_at && (
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
requested {new Date(inv.requested_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip hint="Approve — whitelist this email and email them they're in.">
|
||||||
|
<button
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={busy}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||||
|
aria-label="Approve"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip hint="Deny this request.">
|
||||||
|
<button
|
||||||
|
onClick={onDeny}
|
||||||
|
disabled={busy}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
||||||
|
aria-label="Deny"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,20 @@ export interface Me {
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
|
pending_invites: number;
|
||||||
preferences: Record<string, any>;
|
preferences: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Invite {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
status: "pending" | "approved" | "denied";
|
||||||
|
note: string | null;
|
||||||
|
requested_at: string | null;
|
||||||
|
decided_at: string | null;
|
||||||
|
decided_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -203,6 +214,17 @@ export const api = {
|
||||||
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||||
|
|
||||||
|
// --- onboarding / admin ---
|
||||||
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
|
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||||
|
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||||
|
approveInvite: (id: number) =>
|
||||||
|
req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
||||||
|
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
||||||
|
addInvite: (email: string) =>
|
||||||
|
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
|
||||||
// --- user tags ---
|
// --- user tags ---
|
||||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue