diff --git a/.env.example b/.env.example index eba2c14..5f43160 100644 --- a/.env.example +++ b/.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.: diff --git a/backend/alembic/versions/0008_invites.py b/backend/alembic/versions/0008_invites.py new file mode 100644 index 0000000..abbaafc --- /dev/null +++ b/backend/alembic/versions/0008_invites.py @@ -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") diff --git a/backend/app/auth.py b/backend/app/auth.py index b765d9c..d2f5e2f 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 "") - 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 "") + # 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: diff --git a/backend/app/config.py b/backend/app/config.py index f496a10..b99a66f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 "; 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. diff --git a/backend/app/email.py b/backend/app/email.py new file mode 100644 index 0000000..e700802 --- /dev/null +++ b/backend/app/email.py @@ -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", + ) diff --git a/backend/app/main.py b/backend/app/main.py index bdce514..4fbea0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" diff --git a/backend/app/models.py b/backend/app/models.py index 7748605..f3341ed 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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.""" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..c544f4b --- /dev/null +++ b/backend/app/routes/admin.py @@ -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) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index ef532c7..1da878d 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -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 {}, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 806586d..7e1e7a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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]); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 3c20228..31b5774 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -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(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 (
@@ -16,7 +45,47 @@ export default function Login() { > Sign in with Google -
Invite-only access.
+ +
+ {phase === "approved" ? ( +

+ You're already approved — just sign in with Google above. +

+ ) : done ? ( +

+ Thanks — your request is in. We'll email you when it's approved. +

+ ) : ( + <> +
+ No access yet? +
+ {bounced === "denied" && ( +

+ That Google account isn't approved. Request access below. +

+ )} +
+ 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" + /> + +
+ {error &&

{error}

} + + )} +
); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 7ce8db8..64b9bf4 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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 ( + <>
{me.avatar_url ? ( @@ -478,5 +479,148 @@ function Account({ me }: { me: Me }) {
+ {me.role === "admin" && } + + ); +} + +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 ( +
+ {pending.length === 0 ? ( +

No pending requests.

+ ) : ( +
+ {pending.map((i) => ( + approve.mutate(i.id)} + onDeny={() => deny.mutate(i.id)} + busy={approve.isPending || deny.isPending} + /> + ))} +
+ )} + +
{ + e.preventDefault(); + if (newEmail.trim()) add.mutate(newEmail.trim()); + }} + className="flex items-center gap-2 mt-3" + > + 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" + /> + +
+ + {decided.length > 0 && ( +
+ + {decided.length} decided + +
+ {decided.map((i) => ( +
+ {i.email} + + {i.status} + +
+ ))} +
+
+ )} +
+ ); +} + +function InviteRow({ + inv, + onApprove, + onDeny, + busy, +}: { + inv: Invite; + onApprove: () => void; + onDeny: () => void; + busy: boolean; +}) { + return ( +
+
+
{inv.email}
+ {inv.requested_at && ( +
+ requested {new Date(inv.requested_at).toLocaleString()} +
+ )} +
+ + + + + + +
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4aafc50..cf50bcb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,9 +7,20 @@ export interface Me { avatar_url: string | null; role: string; can_write: boolean; + pending_invites: number; preferences: Record; } +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 => + 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) }),