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
a4b1ea5c19
commit
d9caf12202
13 changed files with 605 additions and 15 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue