feat(pool): one board of pools with split/merge by drag; plan names from tier and seat

- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
This commit is contained in:
2026-10-28 11:47:19 +07:00
parent 9d07f64154
commit 26aabd6660
18 changed files with 1638 additions and 642 deletions
@@ -800,7 +800,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
Boolean
);
if (parts.length === 0) return null;
+152 -285
View File
@@ -1,15 +1,15 @@
/**
* @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
* @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
@@ -17,11 +17,11 @@
* 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
* (`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=<tool>&poolProfile=<name>`,
* which opens that tool's tab and Add on that profile; the header links back
* to the profiles.
* which opens Add on that profile; the header links back to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +29,7 @@ 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, Share2, Trash2, UserRound, Users } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
import {
api,
type AccountPin,
@@ -47,10 +47,10 @@ 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";
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
import { MemberPicker } from "./MemberPicker";
type Row = {
tool: PoolTool;
@@ -69,6 +69,8 @@ type Row = {
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. */
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
/** 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" },
];
/** 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<PoolTool>(["claude", "codex"]);
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const profile = params.get("poolProfile");
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
const [tab, setTab] = useState<string>("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
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const tabs = [
...FIXED_TABS,
...aiTools.tools
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
.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 => {
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;
};
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 (
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")}
</Link>
{isAdmin && (
<label className="relative">
<span className="btn-primary pointer-events-none text-xs">
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
</span>
<select
className="absolute inset-0 cursor-pointer opacity-0"
value=""
onChange={(e) => {
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
}}
aria-label={t("pool.addAccount")}
data-testid="pool-add-account"
>
<option value="">{t("pool.addAccount")}</option>
{POOL_TOOL_LABELS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</label>
)}
<button
type="button"
onClick={refresh}
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{pool && (
<PoolGroups
pool={pool}
isAdmin={isAdmin}
onChanged={() => void load()}
renderAccount={(tool, name) => {
const r = rowOf(tool, name);
return r ? (
<AccountCard
row={r}
pool={pool}
isAdmin={isAdmin}
onEdit={() => setEditing(r)}
onRemove={() => setRemoving(r)}
/>
) : null;
}}
/>
)}
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.otherTools")}
</h3>
<div
role="tablist"
aria-label={t("pool.title")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
aria-label={t("pool.otherTools")}
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
})}
</div>
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
<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" ? (
{current.id === "omp" ? (
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : (
<NoPoolPanel id={current.id} label={current.label} />
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{adding && (
<AddAccountDialog
tool={adding.tool}
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd}
onDone={(name) => {
@@ -351,89 +380,16 @@ 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. */
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
function AccountCard({
row: r,
pool,
grouped,
isAdmin,
onEdit,
onRemove,
}: {
row: Row;
pool: AccountPool;
grouped: boolean;
isAdmin: boolean;
onEdit: () => void;
onRemove: () => void;
@@ -443,39 +399,52 @@ function AccountCard({
const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username);
const inGroups = groupsOf(pool, r.tool, r.name);
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
return (
<div
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
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 className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
{r.plan && (
<span
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
data-testid="pool-account-plan"
>
{r.plan}
</span>
)}
</div>
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
</div>
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
{t(`pool.kindShort.${r.kind}`)}
</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0">
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
className="btn-icon p-1"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
className="btn-icon p-1 hover:text-status-danger"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
@@ -488,68 +457,47 @@ function AccountCard({
</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}
>
<span className={`badge py-0 text-[10px] ${warn}`} 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>
)}
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{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">
{metered && (
<div className="space-y-1" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1 text-fg-secondary">
<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>
{metered && (
<>
<span aria-hidden>·</span>
<span>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
<span className="basis-full">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</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>
);
}
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
<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 ${
className={`block h-full transition-[width] duration-500 ${
left == null
? ""
: left === 0
@@ -596,72 +544,6 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
);
}
/**
* 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
@@ -777,13 +659,8 @@ function EditAccountDialog({
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 groupOf = (userId: number) =>
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
const submit = async () => {
if (!ready || busy) return;
@@ -822,38 +699,28 @@ function EditAccountDialog({
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 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 elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
<div className="mt-1">
<MemberPicker
users={users}
chosen={chosen}
onChange={setChosen}
groupOf={groupOf}
grouped={(pool.groups ?? []).length > 0}
note={(u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
const group = groupOf(u.id);
return (
<span className="ml-1.5 text-[11px] text-fg-muted">
{[
group && t("pool.groups.inGroup", { group }),
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
]
.filter(Boolean)
.join(" · ")}
</span>
);
}}
/>
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
@@ -0,0 +1,186 @@
/**
* @file MemberPicker.tsx
* @description The pool dialogs' member list (an account's Edit, a group's
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
* count) plus "No group" for the users in no pool group, a search box, a
* tick-all for what is shown, and one checkbox per user with their role and
* where they are now. Pure UI: the caller owns the ticked set.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
/** The chips' filter applied to one user. */
export function matchesFilter(
u: Pick<ServerUserUsage, "id" | "role">,
filter: MemberFilter,
groupOf: (userId: number) => string | null
): boolean {
if (filter === "all") return true;
if (filter === "nogroup") return groupOf(u.id) === null;
return u.role === filter;
}
/** A row of filter chips with counts; `nogroup` only when groups exist. */
export function MemberFilterChips({
users,
filter,
onFilter,
groupOf,
grouped,
}: {
users: Pick<ServerUserUsage, "id" | "role">[];
filter: MemberFilter;
onFilter: (f: MemberFilter) => void;
groupOf: (userId: number) => string | null;
grouped: boolean;
}) {
const { t } = useTranslation("users");
const chips: { id: MemberFilter; label: string }[] = [
{ id: "all", label: t("pool.members.all") },
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
];
return (
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
{chips.map((c) => {
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
const on = filter === c.id;
return (
<button
key={c.id}
type="button"
aria-pressed={on}
onClick={() => onFilter(c.id)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
on
? "border-accent/50 bg-accent/15 text-accent"
: "border-border text-fg-muted hover:text-fg-secondary"
}`}
>
{c.label}
<span className="tabular-nums opacity-70">{n}</span>
</button>
);
})}
</div>
);
}
export function MemberPicker({
users,
chosen,
onChange,
groupOf,
grouped,
note,
}: {
users: ServerUserUsage[] | null;
chosen: Set<number>;
onChange: (next: Set<number>) => void;
/** The pool group a user is in now, by name, or null. */
groupOf: (userId: number) => string | null;
/** Whether any pool group exists (shows the "No group" chip). */
grouped: boolean;
/** Extra text after a user's name (where they run now). */
note?: (u: ServerUserUsage) => ReactNode;
}) {
const { t } = useTranslation("users");
const [filter, setFilter] = useState<MemberFilter>("all");
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const shown = (users ?? []).filter(
(u) =>
matchesFilter(u, filter, groupOf) &&
(!q ||
u.username.toLowerCase().includes(q) ||
(u.displayName ?? "").toLowerCase().includes(q))
);
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
const setMany = (ids: number[], on: boolean) => {
const next = new Set(chosen);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
onChange(next);
};
return (
<div className="space-y-2" data-testid="member-picker">
{users && users.length > 0 && (
<>
<MemberFilterChips
users={users}
filter={filter}
onFilter={setFilter}
groupOf={groupOf}
grouped={grouped}
/>
<div className="flex items-center gap-2">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
className="input w-full py-1 pl-7 text-xs"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("pool.members.search")}
aria-label={t("pool.members.search")}
/>
</label>
<Checkbox
checked={allShown}
disabled={shown.length === 0}
onChange={(on) =>
setMany(
shown.map((u) => u.id),
on
)
}
label={t("pool.members.tickShown", { count: shown.length })}
labelClassName="text-[11px] text-fg-secondary"
/>
</div>
</>
)}
<div className="max-h-56 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 && users.length > 0 && shown.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
)}
{shown.map((u) => (
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)}
label={
<span>
{u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)}
</span>
}
className="min-w-0 flex-1"
labelClassName="text-sm text-fg-primary"
/>
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
</div>
))}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,24 @@
/**
* @file AccountPoolCard.test.tsx
* @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
* @description The Users page's account pool card: the pools board with every
* tool's accounts (live panes and quota) and members per pool, then a tab per
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
* other installed 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
* sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* The board moves members and accounts between pools (drag, or a column's
* menu) — an account sits in one pool, so a move takes it out of the other —
* splits an account off onto New pool, merges a pool by dragging its only
* account into another, adds a saved profile to a pool, shows each account's
* plan, and saves every change with ONE password.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { AccountPool } from "../../../lib/api";
@@ -129,6 +134,28 @@ const OMP = {
gateway: { running: false },
};
/** The account names a pool column holds (its move menu names others too). */
const held = (col: HTMLElement) =>
within(col)
.queryAllByTestId("pool-group-account")
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
/** A drag-and-drop between two elements, through one shared DataTransfer. */
function drag(from: Element, to: Element) {
const store: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (store[k] = v),
getData: (k: string) => store[k] ?? "",
get types() {
return Object.keys(store);
},
effectAllowed: "",
};
fireEvent.dragStart(from, { dataTransfer });
fireEvent.dragOver(to, { dataTransfer });
fireEvent.drop(to, { dataTransfer });
}
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({
@@ -147,8 +174,8 @@ beforeEach(() => {
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
{ id: 7, username: "mia", role: "developer", disabled: false },
{ id: 8, username: "leo", role: "analyst", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({
@@ -166,11 +193,15 @@ beforeEach(() => {
});
describe("AccountPoolCard", () => {
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
const user = userEvent.setup();
const { unmount } = render(card(false));
render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.queryByText("cx-1")).toBeNull();
// With no pool made yet, every account sits in the shared pool, by tool.
const shared = screen.getByTestId("pool-shared");
expect(shared).toHaveTextContent("Claude Code2");
expect(shared).toHaveTextContent("Codex1");
expect(shared).toHaveTextContent("cx-1");
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");
@@ -188,39 +219,25 @@ describe("AccountPoolCard", () => {
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
expect(account).toHaveTextContent("2 live");
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
// Without groups nothing is said about them on an account.
expect(screen.queryByText("Shared pool")).toBeNull();
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
// An analyst reads: no add, remove or new pool.
expect(screen.queryByTestId("pool-add-account")).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.
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
// Pool tools have no tab any more; an installed tool without a pool does.
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
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 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 () => {
it("adds a Cursor API key from Add account", 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" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
const dialog = await screen.findByRole("dialog");
expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1");
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
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 () => {
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
});
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.add.mockResolvedValue({ account: { name: "work" } });
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" }));
const teamA = await screen.findByTestId("pool-group");
expect(teamA).toHaveTextContent("leo");
expect(teamA).toHaveTextContent("team-1");
await screen.findByText("mia");
await user.click(screen.getByRole("button", { name: "New pool" }));
const names = screen.getAllByLabelText("Pool name");
await user.type(names[names.length - 1]!, "Team B");
const teamB = screen.getAllByTestId("pool-group")[1]!;
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
expect(teamB).toHaveTextContent("mia");
const add = within(teamB).getByLabelText("Move an account into Team B");
await user.selectOptions(add, "codex|cx-1");
await user.selectOptions(add, "claude|profile:work");
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
expect(accountPool.updateGroup).not.toHaveBeenCalled();
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
2,
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
"boss-pass"
)
);
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
// Team A did not change: nothing is sent for it.
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
});
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
],
});
accountPool.updateGroup.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
const teamA = await screen.findByTestId("pool-group");
const shared = screen.getByTestId("pool-shared");
const item = (col: HTMLElement, name: string) =>
within(col)
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes(name))!;
drag(item(shared, "team-2"), teamA);
expect(held(teamA)).toEqual(["team-1", "team-2"]);
// And back again: an account is in one pool, so it leaves the other.
drag(item(teamA, "team-1"), shared);
expect(held(teamA)).toEqual(["team-2"]);
expect(held(shared)).toContain("team-1");
expect(teamA).toHaveTextContent("team-2");
await user.click(
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
);
expect(shared).toHaveTextContent("leo");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
name: "Team A",
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
},
"boss-pass"
)
);
});
it("shows each account's plan on its card", async () => {
accountPool.list.mockResolvedValue({
...POOL,
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
});
render(card(false));
const [first, second] = await screen.findAllByTestId("pool-account");
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
});
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
{
id: 2,
name: "Team B",
users: [{ userId: 7, username: "mia" }],
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
},
],
});
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.deleteGroup.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
await screen.findByText("mia");
const item = (col: HTMLElement, name: string) =>
within(col)
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes(name))!;
// Split: team-2 leaves the shared pool for a new pool of its own.
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
const pools = screen.getAllByTestId("pool-group");
expect(pools).toHaveLength(3);
expect(held(pools[2]!)).toEqual(["team-2"]);
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
// Merge: Team B's only account into Team A brings mia along; Team B goes.
drag(item(teamB!, "cx-1"), teamA!);
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
expect(teamA).toHaveTextContent("mia");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
name: "Team A",
userIds: [8, 7],
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
},
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
expect(accountPool.updateGroup).toHaveBeenCalledWith(
3,
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
"boss-pass"
);
// The members moved before the emptied pool was deleted.
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]!;
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
});
it("turns Oh My Pi login sharing on from its tab", async () => {
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
const dialog = await screen.findByRole("dialog");
const select = await screen.findByLabelText("Profile");
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
const dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2");
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
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" }));
@@ -425,7 +573,6 @@ 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.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(