/** * @file AccountPoolCard.tsx * @description The Users page's account pool card (`GET /api/accounts`): the * Claude and Codex accounts every user's new sessions are spread over, each * with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in * per user. An admin adds one — for Claude a login MOVED in from a saved * profile (Settings → AI tools → Profiles, `GET /api/accounts/profiles`), a * pasted `.credentials.json`, or a `claude setup-token` token; for Codex a * saved Codex profile (`?tool=codex`), an OpenAI API key or a pasted * `auth.json` — or removes one, each time with their own * password again; an analyst only reads. The secret only goes IN: the server * never sends it back. An account's Edit (pencil) picks the users who run on * it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and * a Claude login goes into their home at once — no restart. A profile's * "Move to pool" link lands here as `?poolAdd=&poolProfile=`, * which opens Add on that profile; the header links back to the profiles. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Link, useSearchParams } from "react-router-dom"; import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { api, type AccountPin, type AccountPool, type PoolAccountInput, type PoolProfile, type PoolTool, 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 { ConfirmModal } from "../ConfirmModal"; import { Checkbox } from "../Checkbox"; type Row = { tool: PoolTool; name: string; kind: string; email: string | null; refreshError: string | null; addedAt: string; expired: boolean; livePanes: number; spend5h: number; spend7d: number; tokens5h: number; tokens7d: number; quotaPercent: number | null; full: boolean; }; function rowsOf(pool: AccountPool): Row[] { return [ ...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })), ...pool.codex.map((a) => ({ ...a, tool: "codex" as const, expired: false, email: null, refreshError: null, })), ]; } const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? []; function errorText(t: TFunction<"users">, e: unknown): string { const err = e as Error & { code?: string }; return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" }) ? t(`pool.errors.${err.code}`) : err.message; } export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { const { t } = useTranslation("users"); const [pool, setPool] = useState(null); const [error, setError] = useState(null); const [params, setParams] = useSearchParams(); // A profile's "Move to pool" (Settings → AI tools → Profiles) lands here. const preset = (() => { const tool = params.get("poolAdd"); const profile = params.get("poolProfile"); return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null; })() as { tool: PoolTool; profile: string } | null; const [adding, setAdding] = useState(() => isAdmin && preset !== null); const closeAdd = () => { setAdding(false); if (!preset) return; const next = new URLSearchParams(params); next.delete("poolAdd"); next.delete("poolProfile"); setParams(next, { replace: true }); }; const [removing, setRemoving] = useState(null); const [editing, setEditing] = useState(null); const load = useCallback( (force = false) => api.accountPool .list(force) .then((r) => { setPool(r); setError(null); }) .catch((e: Error) => setError(e.message)), [] ); const [spinning, refresh] = useSpin(() => load(true)); useEffect(() => { void load(); }, [load]); const rows = pool ? rowsOf(pool) : []; const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; return (

{t("pool.title")}

{t("pool.description")}

{t("pool.profilesLink")} {isAdmin && ( )}
{error && (

{error}

)} {pool && rows.length === 0 && (

{t("pool.empty")}

)} {rows.length > 0 && (
{isAdmin && } {rows.map((r) => ( {isAdmin && ( )} ))}
{t("pool.account")} {t("pool.tool")} {t("pool.livePanes")} {t("pool.quota")} {t("pool.spend")}{t("actions")}
{r.name}
{r.email &&
{r.email}
}
{t("pool.added", { at: formatDateTime(r.addedAt) })}
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
{t("pool.pinned", { users: pinsOf(pool, r.tool) .filter((p) => p.account === r.name) .map((p) => p.username) .join(", "), })}
)}
{t(`pool.kind.${r.kind}`)} {r.expired && ( {t("pool.expired")} )} {r.refreshError && !r.expired && ( {t("pool.refreshFailed")} )} {r.full && ( {t("pool.full")} )} {r.livePanes} {r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
{fmtCost(r.spend5h)} {t("pool.tokens", { tokens: fmt(r.tokens5h) })}
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
)} {adding && ( { toast.success(t("pool.done.added", { name })); closeAdd(); void load(); }} /> )} {removing && ( setRemoving(null)} onDone={() => { toast.success(t("pool.done.removed", { name: removing.name })); setRemoving(null); void load(); }} /> )} {editing && pool && ( setEditing(null)} onDone={(r) => { const left = r.added.filter( (u) => u.switched === "own-login" || u.switched === "expired" ); toast.success(t("pool.editDone", { name: editing.name })); for (const u of left) { toast.info( t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name }) ); } setEditing(null); void load(); }} /> )}
); } /** * One account's Edit: tick the users whose sessions run on it. A user already * on another account of the tool moves here when ticked; unticking one sends * them back to the least-used account. Saved in one request, password again. */ function EditAccountDialog({ pool, row, onClose, onDone, }: { pool: AccountPool; row: Row; onClose: () => void; onDone: (r: Awaited>) => void; }) { const { t } = useTranslation("users"); const pins = pinsOf(pool, row.tool); const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId); const [users, setUsers] = useState(null); const [chosen, setChosen] = useState>(() => new Set(initial)); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { api.users .list() .then((r) => setUsers(r.users)) .catch(() => setUsers([])); }, []); const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id)); const ready = changed && current.length > 0; const toggle = (id: number, on: boolean) => setChosen((prev) => { const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; }); const submit = async () => { if (!ready || busy) return; setBusy(true); setError(null); try { onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current)); } catch (e) { setError(errorText(t, e)); } finally { setBusy(false); } }; return ( void submit()} onCancel={onClose} >
{ if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) { e.preventDefault(); void submit(); } }} >
{t("pool.users")}
{users === null && (

{t("pool.loadingUsers")}

)} {users?.length === 0 && (

{t("pool.noUsers")}

)} {users?.map((u) => { const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); return (
toggle(u.id, on)} label={ {u.username} {u.disabled && ( ({t("disabled")}) )} {elsewhere && ( {t("pool.onAccount", { account: elsewhere.account })} )} } labelClassName="text-sm text-fg-primary" />
); })}
setCurrent(e.target.value)} autoComplete="current-password" aria-label={t("yourPassword")} /> {error && (

{error}

)}
); } type ClaudeSource = "profile" | "credentials" | "token"; /** What each tool's pool account can come from (server/routes/accounts.js). */ const SOURCES: Record = { claude: ["profile", "credentials", "token"], codex: ["profile", "token"], }; function AddAccountDialog({ preset, onClose, onDone, }: { /** A saved profile to move in, from its "Move to pool" link. */ preset?: { tool: PoolTool; profile: string } | null; onClose: () => void; onDone: (name: string) => void; }) { const { t } = useTranslation("users"); const [tool, setTool] = useState(preset?.tool ?? "claude"); const [source, setSource] = useState("profile"); const [profiles, setProfiles] = useState(null); const [profile, setProfile] = useState(preset?.profile ?? ""); const [name, setName] = useState(""); const [secret, setSecret] = useState(""); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { setProfiles(null); api.accountPool .profiles(tool) .then((r) => setProfiles(r.profiles)) .catch(() => setProfiles([])); }, [tool]); const fromProfile = source === "profile"; // A profile already names itself: its name is the default. const ready = current.length > 0 && (fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0); const submit = async () => { if (!ready || busy) return; setBusy(true); setError(null); const body: PoolAccountInput = source === "profile" ? { tool, name: name.trim() || profile, profile } : tool === "codex" ? { tool, name: name.trim(), token: secret.trim() } : source === "credentials" ? { tool, name: name.trim(), credentials: secret.trim() } : { tool, name: name.trim(), token: secret.trim() }; try { const r = await api.accountPool.add(body, current); onDone(r.account.name); } catch (e) { setError(errorText(t, e)); } finally { setBusy(false); } }; const onKeyDown = (e: KeyboardEvent) => { // The secret box is a textarea for pasted JSON: Enter there is a newline. if ((e.target as HTMLElement).tagName !== "TEXTAREA" && isSubmitEnter(e)) { e.preventDefault(); void submit(); } }; const secretKind = tool === "codex" ? "codex" : source; return ( void submit()} onCancel={onClose} >
{fromProfile && ( )} setName(e.target.value)} autoComplete="off" aria-label={t("pool.name")} /> {!fromProfile && ( {secretKind === "token" ? ( setSecret(e.target.value)} autoComplete="off" aria-label={t(`pool.secret.${secretKind}`)} /> ) : (