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.
|
||||
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 ---
|
||||
# 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.:
|
||||
|
|
|
|||
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 {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
saveLayoutLocal,
|
||||
type SidebarLayout,
|
||||
} from "./lib/sidebarLayout";
|
||||
import { configureNotifications } from "./lib/notifications";
|
||||
import { configureNotifications, notify } from "./lib/notifications";
|
||||
import { setHintsEnabled } from "./lib/hints";
|
||||
import Login from "./components/Login";
|
||||
import Header from "./components/Header";
|
||||
|
|
@ -104,6 +104,15 @@ export default function App() {
|
|||
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||
if (typeof prefs.performanceMode === "boolean")
|
||||
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
|
||||
}, [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() {
|
||||
// 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 (
|
||||
<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">
|
||||
|
|
@ -16,7 +45,47 @@ export default function Login() {
|
|||
>
|
||||
Sign in with Google
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
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 { api, type Me } from "../lib/api";
|
||||
import { api, type Invite, type Me } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
import {
|
||||
configureNotifications,
|
||||
|
|
@ -434,6 +434,7 @@ function Sync({ me }: { me: Me }) {
|
|||
|
||||
function Account({ me }: { me: Me }) {
|
||||
return (
|
||||
<>
|
||||
<Section title="Account">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{me.avatar_url ? (
|
||||
|
|
@ -478,5 +479,148 @@ function Account({ me }: { me: Me }) {
|
|||
</div>
|
||||
</div>
|
||||
</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;
|
||||
role: string;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
@ -203,6 +214,17 @@ export const api = {
|
|||
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
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 ---
|
||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue