merge: M5c — onboarding (DB invites, request-access, admin approval, email)

This commit is contained in:
npeter83 2026-06-12 01:43:14 +02:00
commit a92112cefb
13 changed files with 605 additions and 15 deletions

View file

@ -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.:

View 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")

View file

@ -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:

View file

@ -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
View 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",
)

View file

@ -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"

View file

@ -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
View 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)

View file

@ -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 {},
} }

View file

@ -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]);

View file

@ -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>
); );

View file

@ -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>
); );
} }

View file

@ -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) }),