/** * @file AccountPoolCard.tsx * @description The Users page's account pool card (`GET /api/accounts`), one * tab per AI tool so each tool's pool is managed on its own: Claude Code, * Codex and Cursor have a pool — the accounts every user's new sessions of that * tool are spread over, each with its live panes (and, for Claude/Codex, $ and * tokens in the last 5 h / 7 d and quota) — so nobody signs in per user; * Oh My Pi has no pool but can share the host's login (the same switch as * Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every * other installed tool says that its users sign in themselves. A tab shows * how many accounts its pool holds, and an empty pool says so instead of * vanishing. An admin adds an account to the open tab's tool — 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`; for Cursor a * Cursor API key — 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 that tool's tab and 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, Share2, Trash2, UserRound, Users } from "lucide-react"; import { api, type AccountPin, type AccountPool, type OmpSharingState, type PoolAccountInput, type PoolProfile, type PoolTool, type ServerUserUsage, type CcUsageWindow, } 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 { useAiTools } from "../../hooks/useAiTools"; import { ConfirmModal } from "../ConfirmModal"; import { Checkbox } from "../Checkbox"; import { AiToolIcon } from "../AiToolIcon"; import { SettingsSwitchRow } from "../settings/SettingsRow"; import { PoolGroups, groupsOf } from "./PoolGroups"; 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; /** The 5-hour and weekly windows, as the account last reported them. */ windows: QuotaWindow[]; }; /** One quota window of an account: % used and when it rolls over. */ type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null }; /** * The 5-hour and weekly windows of an account, from Claude's `fiveHour` / * `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has * passed is empty again (the reading predates it), as the balancer treats it. */ function windowsOf( a: AccountPool["accounts"][number] | AccountPool["codex"][number] ): QuotaWindow[] { const q = a.quota as | { fiveHour?: CcUsageWindow | null; sevenDay?: CcUsageWindow | null; windows?: { label: string; usedPercent: number; resetsAt: string | null }[]; } | null | undefined; const fromClaude = (w: CcUsageWindow | null | undefined) => w ? { used: w.utilization, resetsAt: w.resetsAt } : null; const fromCodex = (label: string) => { const w = q?.windows?.find((x) => x.label === label); return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null; }; return (["5h", "wk"] as const).map((key) => { const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key); const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now(); return { key, used: passed ? 0 : (w?.used ?? null), resetsAt: passed ? null : (w?.resetsAt ?? null), }; }); } /** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */ function untilText(iso: string | null): string | null { const ms = iso ? Date.parse(iso) - Date.now() : NaN; if (!(ms > 0)) return null; const m = Math.floor(ms / 60_000); const d = Math.floor(m / 1440); const h = Math.floor((m % 1440) / 60); return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`; } /** The tools that have a pool, in tab order (server/routes/accounts.js). */ const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"]; /** Fixed tabs, always shown; any other installed tool follows them. */ const FIXED_TABS: { id: string; label: string }[] = [ { id: "claude", label: "Claude Code" }, { id: "codex", label: "Codex" }, { id: "cursor", label: "Cursor CLI" }, { id: "omp", label: "Oh My Pi" }, ]; /** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */ const METERED = new Set(["claude", "codex"]); const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id); function rowsOf(pool: AccountPool, tool: PoolTool): Row[] { if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) })); const list = tool === "codex" ? pool.codex : (pool.cursor ?? []); return list.map((a) => ({ ...a, tool, expired: false, email: null, refreshError: null, windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [], })); } 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 aiTools = useAiTools(); const [pool, setPool] = useState(null); const [omp, setOmp] = 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 && isPoolTool(tool) && profile ? { tool, profile } : null; })(); const [tab, setTab] = useState(preset?.tool ?? "claude"); // What Add opens on: a tool, and a saved profile to share into its pool. const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() => isAdmin && preset ? preset : null ); const closeAdd = () => { setAdding(null); 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(); // Oh My Pi's tab: the host's login shared through omp's own gateway (S3). api.aiTools .ompSharing() .then(setOmp) .catch(() => setOmp(null)); }, [load]); // Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied. const tabs = [ ...FIXED_TABS, ...aiTools.tools .filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id)) .map((x) => ({ id: x.id, label: x.label })), ]; const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!; const badge = (id: string): string | null => { if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null; if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null; return null; }; return (

{t("pool.title")}

{t("pool.description")}

{t("pool.profilesLink")}
{pool?.groups && void load()} />}
{tabs.map((x) => { const selected = x.id === current.id; const count = badge(x.id); return ( ); })}
{error && (

{error}

)}
{isPoolTool(current.id) ? ( setAdding({ tool: current.id as PoolTool, profile })} onEdit={setEditing} onRemove={setRemoving} /> ) : current.id === "omp" ? ( ) : ( )}
{adding && ( x.id === adding.tool)?.label ?? adding.tool} preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null} onClose={closeAdd} onDone={(name) => { 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 pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */ function PoolPanel({ tool, label, pool, isAdmin, installed, onAdd, onEdit, onRemove, }: { tool: PoolTool; label: string; pool: AccountPool | null; isAdmin: boolean; installed: boolean; /** Add an account; `profile` = share that saved profile into the pool. */ onAdd: (profile?: string) => void; onEdit: (r: Row) => void; onRemove: (r: Row) => void; }) { const { t } = useTranslation("users"); const rows = pool ? rowsOf(pool, tool) : []; const grouped = (pool?.groups ?? []).length > 0; const addButton = isAdmin && ( ); return ( <>

{t(`pool.about.${tool}`)} {!installed && ( {t("pool.notInstalled", { tool: label })} )}

{rows.length > 0 && addButton}
{pool && rows.length === 0 && (

{t("pool.emptyFor", { tool: label })}

{addButton}
)} {rows.length > 0 && (
{rows.map((r) => ( onEdit(r)} onRemove={() => onRemove(r)} /> ))}
)} {isAdmin && SOURCES[tool].includes("profile") && ( onAdd(name)} /> )} ); } /** One pool account: who it is, how loaded, which groups and users run on it. */ function AccountCard({ row: r, pool, grouped, isAdmin, onEdit, onRemove, }: { row: Row; pool: AccountPool; grouped: boolean; isAdmin: boolean; onEdit: () => void; onRemove: () => void; }) { const { t } = useTranslation("users"); const metered = METERED.has(r.tool); const users = pinsOf(pool, r.tool) .filter((p) => p.account === r.name) .map((p) => p.username); const inGroups = groupsOf(pool, r.tool, r.name); return (
{r.name}
{r.email &&
{r.email}
}
{t(`pool.kindShort.${r.kind}`)} {isAdmin && (
)}
{(r.expired || r.refreshError || r.full) && (
{r.expired && ( {t("pool.expired")} )} {r.refreshError && !r.expired && ( {t("pool.refreshFailed")} )} {r.full && ( {t("pool.full")} )}
)}
0 ? "bg-status-success" : "bg-fg-muted/40"}`} aria-hidden /> {t("pool.liveCount", { count: r.livePanes })}
{metered && (
{r.windows.map((w) => ( ))}
)} {metered && (
{t("pool.spend")}: {fmtCost(r.spend5h)} ·{" "} {t("pool.tokens", { tokens: fmt(r.tokens5h) })}
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
)} {grouped && (
{inGroups.length === 0 ? ( {t("pool.groups.shared")} ) : ( inGroups.map((g) => ( {g.name} )) )}
)} {users.length > 0 && (
{t("pool.pinned", { users: users.join(", ") })}
)}
{t("pool.added", { at: formatDateTime(r.addedAt) })}
); } /** * One window of an account's quota, shown as what is LEFT (100% = untouched, * 0% = used up), with when it resets. */ function QuotaBar({ window: w }: { window: QuotaWindow }) { const { t } = useTranslation("users"); const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used))); const resets = untilText(w.resetsAt); return (
{t(`pool.window.${w.key}`)} {left == null ? "—" : `${left}%`}
{resets && (
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
)}
); } /** * The tool's saved login profiles (Settings → AI tools → Profiles) as cards: * "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then * leaves the switcher, since a login's refresh token may have one holder. */ function SavedProfiles({ tool, pool, onShare, }: { tool: PoolTool; /** Re-read whenever the pool changes: a shared profile is gone from the list. */ pool: AccountPool | null; onShare: (name: string) => void; }) { const { t } = useTranslation("users"); const [profiles, setProfiles] = useState(null); useEffect(() => { api.accountPool .profiles(tool) .then((r) => setProfiles(r.profiles)) .catch(() => setProfiles([])); }, [tool, pool]); return (

{t("pool.savedProfiles.title")}

{t("pool.profilesLink")}
{profiles !== null && profiles.length === 0 && (

{t("pool.savedProfiles.empty")}

)} {profiles && profiles.length > 0 && (
{profiles.map((p) => (
{p.label}
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")} {p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
))}
)}
); } /** * Oh My Pi's tab: no pool, but the host's own omp login can be shared with * every member's pane through omp's gateway (server/lib/omp-share.js) — the * same admin switch as Settings → AI tools. Only with per-user accounts (S3). */ function OmpPanel({ state, isAdmin, onChange, }: { state: OmpSharingState | null; isAdmin: boolean; onChange: (s: OmpSharingState) => void; }) { const { t } = useTranslation("users"); const ts = useTranslation("settings").t; const [busy, setBusy] = useState(false); if (!state?.available) { return (

{t("pool.omp.unavailable")}

); } const error = state.broker.error || state.gateway.error; const status = !state.enabled ? t("pool.omp.off") : !state.installed ? ts("ompSharing.notInstalled") : state.broker.running && state.gateway.running ? ts("ompSharing.running") : error ? ts("ompSharing.failed", { error }) : ts("ompSharing.starting"); return (

{t("pool.omp.about")}

{isAdmin ? ( { setBusy(true); try { onChange(await api.aiTools.setOmpSharing(on)); } catch (err) { toast.error(err); } finally { setBusy(false); } }} /> ) : null}

{status}

); } /** Any other tool: no pool — say so, and why for the ones we know. */ function NoPoolPanel({ id, label }: { id: string; label: string }) { const { t } = useTranslation("users"); return (

{t("pool.none.title", { tool: label })}

{t(`pool.none.reason.${id}`, { defaultValue: t("pool.none.reason.default", { tool: label }), })}

); } /** * 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"], cursor: ["token"], }; function AddAccountDialog({ tool, label, preset, onClose, onDone, }: { /** The open tab's tool: the account is added to its pool. */ tool: PoolTool; label: string; /** 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 sources = SOURCES[tool]; const [source, setSource] = useState(sources[0] ?? "token"); const [profiles, setProfiles] = useState(null); const [profile, setProfile] = useState(preset?.tool === tool ? 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(() => { if (!sources.includes("profile")) return; api.accountPool .profiles(tool) .then((r) => setProfiles(r.profiles)) .catch(() => setProfiles([])); }, [tool, sources]); 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 === "claude" && 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(); } }; // Codex's and Cursor's one non-profile source is their own kind of key. const secretKind = tool === "claude" ? source : tool; return ( void submit()} onCancel={onClose} >
{sources.length > 1 && ( )} {fromProfile && ( )} setName(e.target.value)} autoComplete="off" aria-label={t("pool.name")} /> {!fromProfile && ( {secretKind === "token" || secretKind === "cursor" ? ( setSecret(e.target.value)} autoComplete="off" aria-label={t(`pool.secret.${secretKind}`)} /> ) : (