"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.
{/* 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.