/** * @file Users.tsx * @description The Users page (`/users`, a sidebar item), server mode only and * only for an admin or analyst (docs/SERVER-MODE.md): every account with its * role, state, workspaces, sessions and the tokens/$ it used in the last 7 and * 30 days (`GET /api/users`), 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`). 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. * 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 { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { Navigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { AlertCircle, DollarSign, Eye, FolderOpen, MessagesSquare, Pencil, RefreshCw, ScrollText, UserPlus, Users as UsersIcon, } from "lucide-react"; import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api"; import { fmt, fmtCost, 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 { TableRowSkeleton } from "../components/Skeleton"; type Role = AuthUser["role"]; const ROLES: Role[] = ["admin", "analyst", "developer"]; export function Users() { 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); const [audit, setAudit] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const [editing, setEditing] = useState(null); const load = useCallback( () => Promise.all([ api.users .list() .then((r) => { setUsers(r.users); setError(null); }) .catch((e: Error) => setError(e.message)), api.users .audit() .then((r) => setAudit(r.entries)) .catch(() => setAudit([])), ]), [] ); const [spinning, refresh] = useSpin(load); useEffect(() => { if (me && me.role !== "developer") void load(); }, [me, load]); // 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-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; return (

{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")}

)}
{isAdmin && } {!users && !error && Array.from({ length: 4 }).map((_, i) => ( ))} {users?.map((u) => { const self = u.id === me.id; return ( {isAdmin && ( )} ); })}
{t("name")} {t("role")} {t("workspaces")} {t("sessions")} {t("last7d")} {t("last30d")}{t("actions")}
{(u.displayName || u.username).slice(0, 2)}
{u.displayName || u.username} {u.displayName && ( @{u.username} )}
{u.email && (
{u.email}
)}
{self && ( {t("you")} )} {u.disabled && ( {t("disabled")} )}
{t(`roles.${u.role}`)} {u.workspaces} {u.sessions} setEditing(u)}>

{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(); }} /> )} {editing && ( setEditing(null)} onDone={(message) => { toast.success(message); setEditing(null); void load(); }} /> )}
); } /** A period's cost, with its tokens underneath. */ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) { const { t } = useTranslation("users"); return (
{fmtCost(usage.cost)}
{t("tokens", { n: fmt(usage.tokens) })}
); } 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 ( ); }