/** * @file Users.tsx * @description The users section of the Administration page (`/admin`, which * `/users` redirects to — `Administration.tsx`), server mode only and * only for an admin or analyst (docs/SERVER-MODE.md): every account with its * role, state, workspaces, sessions, the $ it used today (the browser's local * day) with 7 and 30 days under it (`GET /api/users`), * how much disk its home folder takes and its share of the homes disk (from * `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on * the Dashboard's System Health tab) * (`GET /api/users/storage`, measured in the background — polled while the * server says `measuring`; Refresh re-measures), and — one Linux user per * account — its own tmux server (`MemberTmux.tsx`: version, outdated badge, * sessions, agents; an admin's restart in the row's actions and "Restart idle" * above the table): every per-user fact in ONE table. Also the audit log * (`GET /api/users/audit`), and — for * an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile, * role, active/disabled, password, owned workspaces, sign out everywhere, sent * as one change), and the Claude/Codex account pool every user's sessions run * on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the * limit, red once used up) and the default quota sits in its own card * (`UserQuota.tsx`); a user's own quota is part of Edit. * Every change asks for the admin's OWN password again (the * server refuses without it); an analyst only reads. A row shows the display * name and email under the username when set, and "New password due" while * the account still has a password an admin gave; right-clicking a row offers * Edit, Open home (in /files), and copying the username or email. Clicking a * row (or its name) expands that account in full right under it — * `UserDetail`, one at a time, kept in `?user=` so a reload or a shared * link lands on it. `children` go between the account pool and the audit log. * Laid out like the other list pages: icon header, a row of totals * (`StatCard`), the accounts in a `card` table, the audit log in a card. * @author Nguyễn Ngọc Trí Vĩ */ import { Fragment, useCallback, useEffect, useState, type KeyboardEvent, type ReactNode, } from "react"; import { Navigate, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { AlertCircle, ChevronRight, DollarSign, Eye, FolderOpen, MessagesSquare, Pencil, RefreshCw, RotateCcw, ScrollText, UserPlus, Users as UsersIcon, } from "lucide-react"; import { api, type AuditEntry, type AuthUser, type MemberTmuxRow, type ServerUserUsage, type UserQuota, type UserStorage, } from "../lib/api"; import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format"; import { toast } from "../lib/toast"; import { isSubmitEnter } from "../lib/keys"; import { useSpin } from "../hooks/useSpin"; import { useAuth } from "../components/AuthGate"; import { ConfirmModal } from "../components/ConfirmModal"; import { StatCard } from "../components/StatCard"; import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog"; import { AccountPoolCard } from "../components/users/AccountPoolCard"; import { UserDetail } from "../components/users/UserDetail"; import { UserAvatar } from "../components/users/UserAvatar"; import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota"; import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux, } from "../components/users/MemberTmux"; import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth"; import { TableRowSkeleton } from "../components/Skeleton"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { copyText } from "../lib/clipboard"; type Role = AuthUser["role"]; const ROLES: Role[] = ["admin", "analyst", "developer"]; export function Users({ embedded = false, host, children, }: { embedded?: boolean; /** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */ host?: HostState; children?: ReactNode; } = {}) { const { t } = useTranslation("users"); const auth = useAuth(); const me = auth.state.mode === "server" ? auth.state.user : null; const isAdmin = me?.role === "admin"; const [users, setUsers] = useState(null); // The default spend quota (`undefined` until loaded or from an older server). const [quotaDefault, setQuotaDefault] = useState(undefined); const [audit, setAudit] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const [editing, setEditing] = useState(null); const rowMenu = useContextMenu(); const navigate = useNavigate(); const [storage, setStorage] = useState | null>(null); const [measuring, setMeasuring] = useState(false); const tmux = useMemberTmux(!!me && me.role !== "developer"); // A member's tmux restart: one member, or every idle outdated one. const [restarting, setRestarting] = useState(null); const [params, setParams] = useSearchParams(); const detailId = Number(params.get("user")) || null; const openDetail = useCallback( (id: number | null) => setParams( (prev) => { const next = new URLSearchParams(prev); if (id == null) next.delete("user"); else next.set("user", String(id)); return next; }, { replace: true } ), [setParams] ); const loadStorage = useCallback( (remeasure = false) => api.users .storage(remeasure) .then((r) => { setStorage(r.storage); setMeasuring(r.measuring); }) .catch(() => setMeasuring(false)), [] ); const load = useCallback( (remeasure = false) => Promise.all([ loadStorage(remeasure), api.users .list() .then((r) => { setUsers(r.users); setQuotaDefault(r.quotaDefault); setError(null); }) .catch((e: Error) => setError(e.message)), api.users .audit() .then((r) => setAudit(r.entries)) .catch(() => setAudit([])), ]), [loadStorage] ); // The Refresh button also re-measures every home; a plain load reuses the // server's cached numbers. const reloadHost = host?.reload; const reloadTmux = tmux.reload; const [spinning, refresh] = useSpin( useCallback( () => Promise.all([load(true), reloadTmux(), reloadHost?.()]), [load, reloadTmux, reloadHost] ) ); useEffect(() => { if (me && me.role !== "developer") void load(); }, [me, load]); // Homes are measured in the background: ask again until every number is in. useEffect(() => { if (!measuring) return; const id = setTimeout(() => void loadStorage(), 4000); return () => clearTimeout(id); }, [measuring, storage, loadStorage]); // Local mode, or a developer: there is no Users page for them. if (!me || me.role === "developer") return ; const sum = (pick: (u: ServerUserUsage) => number) => (users ?? []).reduce((n, u) => n + pick(u), 0); const active = (users ?? []).filter((u) => !u.disabled).length; const th = "px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; const stats = host?.stats ?? null; const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null; const showTmux = !!tmux.data?.available; const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r])); const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0); return (
{!embedded && (
)}
{/* On the Administration page this is one section of it: a section heading. */} {embedded ? (

{t("title")}

) : (

{t("title")}

)}

{t("description")}

{isAdmin && ( )}
u.workspaces)} icon={FolderOpen} loading={!users && !error} /> u.sessions)} icon={MessagesSquare} loading={!users && !error} /> u.last30d.cost))} icon={DollarSign} trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined} raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })} loading={!users && !error} />
{error && (
{error}
)} {!isAdmin && (

{t("readOnly")}

)}
{showTmux && } {isAdmin && } {!users && !error && Array.from({ length: 4 }).map((_, i) => ( ))} {users?.map((u) => { const self = u.id === me.id; const open = detailId === u.id; const toggle = () => openDetail(open ? null : u.id); const tmuxRow = tmuxById.get(u.id); const home = storage ? (storage[u.id] ?? null) : undefined; return ( rowMenu.open(e, u)} onClick={(e) => { if ((e.target as HTMLElement).closest("a,button")) return; toggle(); }} className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`} > {showTmux && ( )} {isAdmin && ( )} {open && ( )} ); })}
{t("name")} {t("role")} {t("activity")} {t("spend")} {t("storage")}{t("tmuxCol")}{t("actions")}
{u.email && (
{u.email}
)}
{self && ( {t("you")} )} {u.disabled && ( {t("disabled")} )} {!u.disabled && u.mustChangePassword && ( {t("mustChange")} )}
{t(`roles.${u.role}`)}
{t("sessionsN", { n: u.sessions })}
{t("workspacesN", { n: u.workspaces })}
{fmtCost((u.today ?? { cost: 0 }).cost)}
{t("spendSub", { week: fmtCost(u.last7d.cost), month: fmtCost(u.last30d.cost), })}
{tmuxRow?.running && ( setRestarting(tmuxRow)} > )} setEditing(u)}>
{rowMenu.menu && (() => { const u = rowMenu.menu.item; const pick = (fn: () => void) => () => { rowMenu.close(); fn(); }; return ( openDetail(detailId === u.id ? null : u.id))}> {t("detail.open", { name: u.displayName || u.username })} {isAdmin && ( setEditing(u))}>{t("edit.button")} )} {isAdmin && !u.disabled && ( navigate( `/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}` ) )} > {t("rowMenu.openHome")} )} {isAdmin && } void copyText(u.username))}> {t("rowMenu.copyUsername")} {u.email && ( void copyText(u.email!))}> {t("rowMenu.copyEmail")} )} ); })()} {quotaDefault !== undefined && ( { toast.success(message); void load(); }} /> )} {children}

{t("auditTitle")}

{t("auditDescription")}

{audit?.length === 0 && (

{t("auditEmpty")}

)} {audit && audit.length > 0 && (
    {audit.map((a) => (
  • {formatDateTime(a.at)} {a.reader_display || a.reader} → {a.owner_display || a.owner} {a.method} {a.path}
  • ))}
)}
{adding && ( setAdding(false)} onDone={(message) => { toast.success(message); setAdding(false); void load(); }} /> )} {restarting && ( setRestarting(null)} onDone={(message) => { toast.success(message); setRestarting(null); void tmux.reload(); }} /> )} {editing && ( setEditing(null)} onDone={(message) => { toast.success(message); setEditing(null); void load(); }} /> )}
); } /** * A user's home folder size (`undefined` = not loaded yet). `null` while the * server is still measuring reads "Measuring…", otherwise "—" (no home of * their own). */ function StorageCell({ storage, measuring, disk, }: { storage?: UserStorage; measuring: boolean; /** The disk holding the homes: the home is also shown as a share of it. */ disk: { total: number } | null; }) { const { t } = useTranslation("users"); if (!storage) { const pending = storage === undefined || measuring; return ( {pending ? t("storageMeasuring") : "—"} ); } const share = disk ? pct(storage.bytes, disk.total) : null; return (
{storage.partial ? "≥ " : ""} {formatBytes(storage.bytes)} {share != null && ({fmtPct(share)})}
{share != null && } ); } function RowAction({ label, disabled, onClick, children, }: { label: string; disabled?: boolean; onClick: () => void; children: ReactNode; }) { return ( ); } /** A new account: username, password, role, then the admin's own password. */ function AddUserDialog({ onClose, onDone, }: { onClose: () => void; onDone: (message: string) => void; }) { const { t } = useTranslation("users"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState("developer"); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const ready = current.length > 0 && username.trim().length > 0 && password.length > 0; const submit = async () => { if (!ready || busy) return; setBusy(true); setError(null); try { const r = await api.users.create({ username: username.trim(), password, role }, current); onDone(t("done.created", { name: r.user.displayName || r.user.username })); } catch (e) { const err = e as Error & { code?: string }; setError( err.code && t(`errors.${err.code}`, { defaultValue: "" }) ? t(`errors.${err.code}`) : err.message ); } finally { setBusy(false); } }; // Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter). const onKeyDown = (e: KeyboardEvent) => { if (isSubmitEnter(e)) { e.preventDefault(); void submit(); } }; return ( void submit()} onCancel={onClose} >
setUsername(e.target.value)} autoComplete="off" aria-label={t("username")} /> setPassword(e.target.value)} autoComplete="new-password" aria-label={t("password")} /> setCurrent(e.target.value)} autoComplete="current-password" aria-label={t("yourPassword")} /> {error && (

{error}

)}
); } function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return ( ); }