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:
npeter83 2026-06-12 01:43:07 +02:00
parent a4b1ea5c19
commit d9caf12202
13 changed files with 605 additions and 15 deletions

View file

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

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() {
// 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>
);

View file

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

View file

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