"use client"; import * as React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Ban, Check, Plus, Send, ShieldCheck, Trash2, Undo2, X } from "lucide-react"; import { api, bytes, type AdminUser } from "@/lib/api"; import { useToast } from "@/components/toast"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export function UsersTable({ users, roles, me, refresh, }: { users: AdminUser[]; roles: string[]; me: string | null; refresh: () => void; }) { const [adding, setAdding] = React.useState(false); return (

Users

Adding someone here is the authorisation, and it binds them to that one provider. If SMTP is set up they also get an email; the link in it carries no token, because possession of the mail isn’t what lets anyone in — the row is.

{adding && ( { setAdding(false); refresh(); }} /> )}
{users.map((u) => ( ))}
); } function UserRow({ u, roles, isMe, refresh, }: { u: AdminUser; roles: string[]; isMe: boolean; refresh: () => void; }) { const toast = useToast(); const [busy, setBusy] = React.useState(false); const [confirmDel, setConfirmDel] = React.useState(false); const run = async (fn: () => Promise, ok: string) => { setBusy(true); try { await fn(); toast(ok); refresh(); } catch (e) { toast((e as Error).message, true); } setBusy(false); }; const quotaPct = u.quota ? (u.usage.requests / u.quota) * 100 : null; return (
{u.avatar && ( /* eslint-disable-next-line @next/next/no-img-element */ (e.currentTarget.style.visibility = "hidden")} /> )}
{u.display || u.username} {u.role === "admin" && } {isMe && (you)}
{u.username} · {u.provider} {u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`} {u.disabled && " · disabled"}
{/* Never signed in and we have somewhere to write: the likeliest reason is they missed the mail, so offer it where that is visible. Resending is harmless — the link carries no token. */} {u.email && !u.last_seen && ( )} {confirmDel ? (
) : ( )}
run(() => api.admin.patchUser(u.username, { quota: v }), "Quota updated")} /> run(() => api.admin.patchUser(u.username, { storage_quota_gb: v }), "Quota updated") } /> {u.bootstrap && ( bootstrap account — disabling won’t lock them out )}
); } /** Number input that only commits on blur or Enter, so a quota isn't PATCHed * once per keystroke while you type "120". */ function QuotaField({ label, value, suffix, pct, onSave, }: { label: string; value: number; suffix: string; pct: number | null; onSave: (v: number) => void; }) { const [v, setV] = React.useState(String(value)); React.useEffect(() => setV(String(value)), [value]); const commit = () => { const n = Number(v); if (!Number.isFinite(n) || n < 0) { setV(String(value)); return; } if (n !== value) onSave(n); }; return ( ); } function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) { const toast = useToast(); const [username, setUsername] = React.useState(""); // Google first: it is the invite path for everyone who is not the owner, // and its username is already the address to write to. const [provider, setProvider] = React.useState("google"); const [role, setRole] = React.useState("user"); const [quota, setQuota] = React.useState("0"); const [storage, setStorage] = React.useState("0"); const [email, setEmail] = React.useState(""); const [busy, setBusy] = React.useState(false); const submit = async () => { if (!username.trim()) { toast("Username required", true); return; } setBusy(true); try { const r = await api.admin.addUser({ username: username.trim(), provider, role, quota: Number(quota) || 0, storage_quota_gb: Number(storage) || 0, ...(provider === "github" && email.trim() ? { email: email.trim() } : {}), }); // Say whether the mail actually went. "Invited" alone would read as // success while the person waits for an email that was never sent. toast( r.invite_sent ? `Invited ${username.trim()} — email sent` : `Invited ${username.trim()} — no email (${r.invite_why})`, ); onDone(); } catch (e) { toast((e as Error).message, true); } setBusy(false); }; const input = "rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " + "text-ink outline-none focus:border-accent-line"; return (
{/* Only GitHub needs this asked. A Google invite already is an address, and a second field for the same value invites them to disagree. */} {provider === "github" && ( )} 0 means unlimited.
); }