feat: account pool per tool with groups and a Cursor pool; update runs in the background

- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
This commit is contained in:
2026-10-22 17:02:08 +07:00
parent 44426faf16
commit 685eb748e8
30 changed files with 2891 additions and 299 deletions
+620 -186
View File
@@ -1,19 +1,27 @@
/**
* @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
* @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=<tool>&poolProfile=<name>`,
* which opens Add on that profile; the header links back to the profiles.
* 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ĩ <vinnt@smartgift.vn>
*/
@@ -21,22 +29,28 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
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 { 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;
@@ -53,19 +67,81 @@ type Row = {
tokens7d: number;
quotaPercent: number | null;
full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[];
};
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,
})),
];
/** 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<PoolTool>(["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] ?? [];
@@ -79,18 +155,24 @@ function errorText(t: TFunction<"users">, e: unknown): string {
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const aiTools = useAiTools();
const [pool, setPool] = useState<AccountPool | null>(null);
const [omp, setOmp] = useState<OmpSharingState | null>(null);
const [error, setError] = useState<string | null>(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);
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>(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(false);
setAdding(null);
if (!preset) return;
const next = new URLSearchParams(params);
next.delete("poolAdd");
@@ -115,16 +197,32 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
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]);
const rows = pool ? rowsOf(pool) : [];
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
// 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 (
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<KeyRound className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-[14rem]">
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
</div>
@@ -142,122 +240,74 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && (
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.add")}
</button>
)}
</div>
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
<div
role="tablist"
aria-label={t("pool.title")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
const count = badge(x.id);
return (
<button
key={x.id}
type="button"
role="tab"
id={`pool-tab-${x.id}`}
aria-selected={selected}
aria-controls="pool-panel"
onClick={() => setTab(x.id)}
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
selected
? "border-accent text-fg-primary font-medium"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
{count !== null && (
<span
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{pool && rows.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
)}
{rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("pool.account")}</th>
<th className={th}>{t("pool.tool")}</th>
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
<th className={`${th} text-right`}>{t("pool.quota")}</th>
<th className={`${th} text-right`}>{t("pool.spend")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((r) => (
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
<td className="px-5 py-2.5">
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
<div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)}
{r.expired && (
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("pool.expired")}
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{r.livePanes}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
</td>
<td className="px-5 py-2.5 text-right tabular-nums">
<div className="text-sm text-fg-primary">
{fmtCost(r.spend5h)}
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
</div>
<div className="text-[11px] text-fg-muted">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</div>
</td>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setRemoving(r)}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
{isPoolTool(current.id) ? (
<PoolPanel
tool={current.id}
label={current.label}
pool={pool}
isAdmin={isAdmin}
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
onEdit={setEditing}
onRemove={setRemoving}
/>
) : current.id === "omp" ? (
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : (
<NoPoolPanel id={current.id} label={current.label} />
)}
</div>
{adding && (
<AddAccountDialog
preset={preset}
tool={adding.tool}
label={tabs.find((x) => 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 }));
@@ -301,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
}
/** 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 && (
<button type="button" onClick={() => onAdd()} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
</button>
);
return (
<>
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
{t(`pool.about.${tool}`)}
{!installed && (
<span className="ml-1 text-status-warning">
{t("pool.notInstalled", { tool: label })}
</span>
)}
</p>
{rows.length > 0 && addButton}
</div>
{pool && rows.length === 0 && (
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
{addButton}
</div>
)}
{rows.length > 0 && (
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((r) => (
<AccountCard
key={r.name}
row={r}
pool={pool!}
grouped={grouped}
isAdmin={isAdmin}
onEdit={() => onEdit(r)}
onRemove={() => onRemove(r)}
/>
))}
</div>
)}
{isAdmin && SOURCES[tool].includes("profile") && (
<SavedProfiles tool={tool} pool={pool} onShare={(name) => 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 (
<div
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
data-testid="pool-account"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
</div>
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
{(r.expired || r.refreshError || r.full) && (
<div className="flex flex-wrap gap-1">
{r.expired && (
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("pool.expired")}
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
<span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden
/>
{t("pool.liveCount", { count: r.livePanes })}
</span>
</div>
{metered && (
<div className="space-y-1.5" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
{metered && (
<div className="text-[11px] tabular-nums text-fg-muted">
<div>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</div>
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
</div>
)}
{grouped && (
<div className="flex flex-wrap items-center gap-1 text-[11px]">
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
{inGroups.length === 0 ? (
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
) : (
inGroups.map((g) => (
<span key={g.id} className="badge py-0 text-[10px]">
{g.name}
</span>
))
)}
</div>
)}
{users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })}
</div>
)}
<div className="mt-auto text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
</div>
);
}
/**
* 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 (
<div className="text-[11px]">
<div className="flex items-center gap-1.5">
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${
left == null
? ""
: left === 0
? "bg-status-danger"
: left <= 10
? "bg-status-warning"
: "bg-accent-solid"
}`}
style={{ width: `${left ?? 0}%` }}
/>
</span>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
{left == null ? "—" : `${left}%`}
</span>
</div>
{resets && (
<div
className="pl-[3.875rem] text-[10px] text-fg-muted"
title={formatDateTime(w.resetsAt!)}
>
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
</div>
)}
</div>
);
}
/**
* 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<PoolProfile[] | null>(null);
useEffect(() => {
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool, pool]);
return (
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
<div className="flex flex-wrap items-center gap-2">
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.savedProfiles.title")}
</h3>
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
{t("pool.profilesLink")}
</Link>
</div>
{profiles !== null && profiles.length === 0 && (
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
)}
{profiles && profiles.length > 0 && (
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
>
<div className="flex-1 min-w-0">
<div className="truncate text-sm text-fg-primary">{p.label}</div>
<div className="truncate text-[11px] text-fg-muted">
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onShare(p.name)}
className="btn-ghost shrink-0 text-xs"
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
>
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
</button>
</div>
))}
</div>
)}
</div>
);
}
/**
* 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 (
<div className="flex items-start gap-3 px-5 py-6">
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
</div>
);
}
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 (
<div className="px-5 py-3 space-y-2">
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
{isAdmin ? (
<SettingsSwitchRow
label={ts("ompSharing.switch")}
description={ts("ompSharing.hint")}
checked={state.enabled}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
onChange(await api.aiTools.setOmpSharing(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
) : null}
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
{status}
</p>
</div>
);
}
/** 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 (
<div className="flex items-start gap-3 px-5 py-6">
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<div className="space-y-1">
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
<p className="text-xs text-fg-muted leading-relaxed">
{t(`pool.none.reason.${id}`, {
defaultValue: t("pool.none.reason.default", { tool: label }),
})}
</p>
</div>
</div>
);
}
/**
* 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
@@ -439,23 +881,29 @@ type ClaudeSource = "profile" | "credentials" | "token";
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
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 [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
const [source, setSource] = useState<ClaudeSource>("profile");
const sources = SOURCES[tool];
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
const [profile, setProfile] = useState(preset?.profile ?? "");
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
const [name, setName] = useState("");
const [secret, setSecret] = useState("");
const [current, setCurrent] = useState("");
@@ -463,12 +911,12 @@ function AddAccountDialog({
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setProfiles(null);
if (!sources.includes("profile")) return;
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool]);
}, [tool, sources]);
const fromProfile = source === "profile";
// A profile already names itself: its name is the default.
@@ -483,11 +931,9 @@ function AddAccountDialog({
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() };
: 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);
@@ -504,12 +950,13 @@ function AddAccountDialog({
void submit();
}
};
const secretKind = tool === "codex" ? "codex" : source;
// Codex's and Cursor's one non-profile source is their own kind of key.
const secretKind = tool === "claude" ? source : tool;
return (
<ConfirmModal
open
title={t("pool.add")}
title={t("pool.addFor", { tool: label })}
confirmLabel={t("pool.add")}
cancelLabel={t("common:cancel")}
destructive={false}
@@ -519,40 +966,27 @@ function AddAccountDialog({
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
<Field label={t("pool.tool")}>
<select
className="input w-full text-sm"
value={tool}
onChange={(e) => {
setTool(e.target.value as PoolTool);
setProfile("");
setSource("profile");
}}
aria-label={t("pool.tool")}
{sources.length > 1 && (
<Field
label={t("pool.source")}
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
>
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
</select>
</Field>
<Field
label={t("pool.source")}
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
>
<select
className="input w-full text-sm"
value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")}
>
{SOURCES[tool].map((s) => (
<option key={s} value={s}>
{tool === "codex" && s === "token"
? t("pool.secret.codex")
: t(`pool.sources.${s}`)}
</option>
))}
</select>
</Field>
<select
className="input w-full text-sm"
value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")}
>
{sources.map((s) => (
<option key={s} value={s}>
{tool !== "claude" && s === "token"
? t(`pool.secret.${tool}`)
: t(`pool.sources.${s}`)}
</option>
))}
</select>
</Field>
)}
{fromProfile && (
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
<select
@@ -592,7 +1026,7 @@ function AddAccountDialog({
</Field>
{!fromProfile && (
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
{secretKind === "token" ? (
{secretKind === "token" || secretKind === "cursor" ? (
<input
type="password"
className="input w-full font-mono text-sm"
+435
View File
@@ -0,0 +1,435 @@
/**
* @file PoolGroups.tsx
* @description The account pool's GROUPS (`/api/accounts/groups`,
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
* members run only on the accounts its group lists, of every tool, while a
* user in no group runs on the shared pool (the accounts no group lists). A
* card shows the group's members and its accounts per tool; an admin creates,
* edits (name, members — one group per user, so ticking one moves them here —
* and accounts) or deletes one, each time with their own password again.
* `groupsOf` tells an account card which groups use it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Pencil, Plus, Trash2, Users } from "lucide-react";
import {
api,
type AccountPool,
type PoolGroup,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
/** Pool tools with their tab labels, in tab order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
];
/** Account names of `tool` in the pool. */
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
return (list ?? []).map((a) => a.name);
}
/** The groups that list account `name` of `tool`. */
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
}
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 PoolGroups({
pool,
isAdmin,
onChanged,
}: {
pool: AccountPool;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation("users");
const groups = pool.groups ?? [];
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
return (
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
<div className="flex flex-wrap items-center gap-2">
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
<div className="flex-1 min-w-[14rem]">
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
</div>
{isAdmin && (
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
</button>
)}
</div>
{groups.length === 0 ? (
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
) : (
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<div
key={g.id}
className="rounded-lg border border-border bg-surface-2 p-3"
data-testid="pool-group"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
<div className="text-[11px] text-fg-muted">
{t("pool.groups.members", { count: g.users.length })}
</div>
</div>
{isAdmin && (
<>
<button
type="button"
onClick={() => setEditing(g)}
title={t("pool.groups.edit", { name: g.name })}
aria-label={t("pool.groups.edit", { name: g.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDeleting(g)}
title={t("pool.groups.delete", { name: g.name })}
aria-label={t("pool.groups.delete", { name: g.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
{g.users.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{g.users.map((u) => (
<span key={u.userId} className="badge py-0 text-[11px]">
{u.username}
</span>
))}
</div>
)}
<div className="mt-2 space-y-1">
{POOL_TOOL_LABELS.map(({ id, label }) => {
const names = g.accounts[id] ?? [];
return (
<div key={id} className="flex items-center gap-1.5 text-[11px]">
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
<span className="text-fg-secondary">{label}:</span>
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{editing && (
<GroupDialog
pool={pool}
group={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onDone={(name) => {
toast.success(t("pool.groups.saved", { name }));
setEditing(null);
onChanged();
}}
/>
)}
{deleting && (
<DeleteGroupDialog
group={deleting}
onClose={() => setDeleting(null)}
onDone={() => {
toast.success(t("pool.groups.deleted", { name: deleting.name }));
setDeleting(null);
onChanged();
}}
/>
)}
</div>
);
}
/** Create a group, or edit one: its name, its members and its accounts. */
function GroupDialog({
pool,
group,
onClose,
onDone,
}: {
pool: AccountPool;
group: PoolGroup | null;
onClose: () => void;
onDone: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [name, setName] = useState(group?.name ?? "");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [members, setMembers] = useState<Set<number>>(
() => new Set(group?.users.map((u) => u.userId) ?? [])
);
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
claude: new Set(group?.accounts.claude ?? []),
codex: new Set(group?.accounts.codex ?? []),
cursor: new Set(group?.accounts.cursor ?? []),
}));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const groupOfUser = (userId: number) =>
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
const next = new Set(set);
if (on) next.add(v);
else next.delete(v);
return next;
};
const ready = name.trim().length > 0 && current.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const id = group
? group.id
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
await api.accountPool.updateGroup(
id,
{
...(group ? { name: name.trim() } : {}),
userIds: [...members],
accounts: Object.fromEntries(
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
),
},
current
);
onDone(name.trim());
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
message={t("pool.groups.hint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
<input
className="input mt-1 w-full text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
aria-label={t("pool.groups.name")}
/>
</label>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.memberList")}
</span>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const other = groupOfUser(u.id);
return (
<div key={u.id} className="px-3 py-1.5">
<Checkbox
checked={members.has(u.id)}
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
label={
<span>
{u.username}
{other && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.groups.inGroup", { group: other.name })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.accountList")}
</span>
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
const names = accountNames(pool, tool);
return (
<div key={tool} className="px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
{label}
</div>
{names.length === 0 ? (
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
) : (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{names.map((n) => (
<Checkbox
key={n}
checked={accounts[tool].has(n)}
onChange={(on) =>
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
}
label={n}
labelClassName="text-sm text-fg-primary"
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function DeleteGroupDialog({
group,
onClose,
onDone,
}: {
group: PoolGroup;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
await api.accountPool.deleteGroup(group.id, current);
onDone();
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.groups.delete", { name: group.name })}
message={t("pool.groups.deleteHint")}
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}
@@ -1,7 +1,9 @@
/**
* @file AccountPoolCard.test.tsx
* @description The Users page's account pool card: lists the Claude and Codex
* accounts with their live panes and quota, says what an empty pool means, an
* @description The Users page's account pool card: one tab per AI tool — the
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
* Oh My Pi's login sharing, and a "no pool" note for any other installed
* tool; says what an empty pool means per tool, an
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
@@ -22,11 +24,20 @@ const accountPool = vi.hoisted(() => ({
remove: vi.fn(),
profiles: vi.fn(),
setUsers: vi.fn(),
createGroup: vi.fn(),
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
const aiTools = vi.hoisted(() => ({
list: vi.fn(),
ompSharing: vi.fn(),
setOmpSharing: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool, users } };
return { ...actual, api: { accountPool, users, aiTools } };
});
import { AccountPoolCard } from "../AccountPoolCard";
@@ -53,9 +64,44 @@ const POOL: AccountPool = {
spend7d: 12,
tokens5h: 250_000,
tokens7d: 3_400_000,
quota: {
fiveHour: {
utilization: 42,
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
},
sevenDay: {
utilization: 15,
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
},
checkedAt: "2026-10-01T00:00:00Z",
error: null,
},
quotaPercent: 42,
full: false,
},
{
name: "team-2",
kind: "token",
email: null,
refreshError: null,
addedAt: "2026-10-01T00:00:00Z",
expiresAt: null,
expired: false,
livePanes: 0,
spend5h: 0,
spend7d: 0,
tokens5h: 0,
tokens7d: 0,
// A 5-hour window that has reset since the reading is full again.
quota: {
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
sevenDay: null,
checkedAt: "2026-10-01T00:00:00Z",
error: null,
},
quotaPercent: null,
full: false,
},
],
codex: [
{
@@ -74,8 +120,30 @@ const POOL: AccountPool = {
blockPercent: 90,
};
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
const OMP = {
available: true,
enabled: false,
installed: true,
broker: { running: false },
gateway: { running: false },
};
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({
tools: [
tool("claude", "Claude Code"),
tool("codex", "Codex"),
tool("cursor", "Cursor CLI"),
tool("omp", "Oh My Pi"),
tool("antigravity", "Antigravity"),
tool("goose", "Goose", false),
],
default: "claude",
});
aiTools.ompSharing.mockResolvedValue(OMP);
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
@@ -98,20 +166,145 @@ beforeEach(() => {
});
describe("AccountPoolCard", () => {
it("lists every account, and explains an empty pool", async () => {
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
const user = userEvent.setup();
const { unmount } = render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
expect(screen.queryByText("cx-1")).toBeNull();
const [account, second] = screen.getAllByTestId("pool-account");
// Every account of the pool has its card; quota is shown as what is LEFT.
expect(second).toHaveTextContent("team-2");
const quota = screen.getAllByTestId("pool-quota")[0]!;
expect(quota).toHaveTextContent("5 hours");
expect(quota).toHaveTextContent("58%");
expect(quota).toHaveTextContent("resets in 2h 10m");
expect(quota).toHaveTextContent("Week");
expect(quota).toHaveTextContent("85%");
expect(quota).toHaveTextContent("resets in 3d 1h");
const reset = screen.getAllByTestId("pool-quota")[1]!;
expect(reset).toHaveTextContent("100%");
expect(reset).toHaveTextContent("—");
expect(account).toHaveTextContent("250.0K tokens");
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
expect(account).toHaveTextContent("2 live");
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
// Without groups nothing is said about them on an account.
expect(screen.queryByText("Shared pool")).toBeNull();
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
const codexTab = screen.getByRole("tab", { name: /Codex/ });
expect(codexTab).toHaveTextContent("1");
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
await user.click(codexTab);
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.queryByText("team-1")).toBeNull();
// A pool with no account still has its tab, with what that means.
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
// An installed tool without a pool says so; an uninstalled one gets no tab.
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
unmount();
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
render(card(true));
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
});
it("adds a Cursor API key from the Cursor tab", async () => {
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
const dialog = await screen.findByRole("dialog");
expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1");
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "cursor", name: "cu-1", token: "key_abc" },
"boss-pass"
)
);
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
});
it("shares a saved profile from its card", async () => {
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
)
);
});
it("shows the groups as cards and saves a new one with members and accounts", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
],
});
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
const groupCard = await screen.findByTestId("pool-group");
expect(groupCard).toHaveTextContent("Team A");
expect(groupCard).toHaveTextContent("leo");
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
await user.click(screen.getByRole("button", { name: "New group" }));
await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Group name"), "Team B");
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
expect(screen.getByText("now in Team A")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
2,
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
"boss-pass"
)
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
});
it("turns Oh My Pi login sharing on from its tab", async () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
});
it("moves a saved login in, the server's own included", async () => {
@@ -119,7 +312,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
const dialog = await screen.findByRole("dialog");
const select = await screen.findByLabelText("Profile");
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
@@ -144,7 +337,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
const dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2");
@@ -168,6 +361,8 @@ describe("AccountPoolCard", () => {
accountPool.remove.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Codex/ }));
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" }));
@@ -230,7 +425,7 @@ describe("AccountPoolCard", () => {
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find(