/** * @file AccountPoolCard.tsx * @description The Users page's account pool card (`GET /api/accounts`). * Claude Code, Codex and Cursor accounts live on the POOLS board * (`PoolGroups`): one column per pool with its accounts of every tool — each * a card with its live panes (and, for Claude/Codex, $ and tokens in the last * 5 h / 7 d and quota) — and its members, so nobody signs in per user. The * tools with no pool get a tab each below it: Oh My Pi 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. An admin adds an account from the * header's Add account, picking its 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`, from a `MemberPicker` filtered by role * or "no group"): 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, Share2, Trash2, UserRound } 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 { AiToolIcon } from "../AiToolIcon"; import { SettingsSwitchRow } from "../settings/SettingsRow"; import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups"; import { MemberPicker } from "./MemberPicker"; 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[]; /** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */ plan?: string | null; }; /** One quota window of an account: % used and when it rolls over. */ export 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. */ export function windowsOf( a: Pick ): 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"]; /** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */ const FIXED_TABS: { id: string; label: string }[] = [{ 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("omp"); // 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 && !isPoolTool(x.id) && !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 => id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null; const rowOf = (tool: PoolTool, name: string) => pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined; return (

{t("pool.title")}

{t("pool.description")}

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

{error}

)} {pool && ( void load()} renderAccount={(tool, name) => { const r = rowOf(tool, name); return r ? ( setEditing(r)} onRemove={() => setRemoving(r)} /> ) : null; }} /> )}

{t("pool.otherTools")}

{tabs.map((x) => { const selected = x.id === current.id; const count = badge(x.id); return ( ); })}
{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 account: who it is and on which plan, how loaded, who is pinned to it. */ function AccountCard({ row: r, pool, isAdmin, onEdit, onRemove, }: { row: Row; pool: AccountPool; 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 warn = "bg-status-warning/10 text-status-warning border-status-warning/20"; return (
{r.name} {r.plan && ( {r.plan} )}
{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")}}
)} {metered && (
{r.windows.map((w) => ( ))}
)}
0 ? "bg-status-success" : "bg-fg-muted/40"}`} aria-hidden /> {t("pool.liveCount", { count: r.livePanes })} {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) })} )}
{users.length > 0 && (
{t("pool.pinned", { users: users.join(", ") })}
)}
); } /** * One window of an account's quota, shown as what is LEFT (100% = untouched, * 0% = used up), with when it resets. */ export 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!) })}
)}
); } /** * 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 groupOf = (userId: number) => (pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null; 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")}
0} note={(u) => { const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); const group = groupOf(u.id); return ( {[ group && t("pool.groups.inGroup", { group }), elsewhere && t("pool.onAccount", { account: elsewhere.account }), ] .filter(Boolean) .join(" · ")} ); }} />
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}`)} /> ) : (