Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 9e3d6f4ca1 chore: v2.8.30 — account pool as one board of pools, real Claude plan names 2026-10-28 11:47:19 +07:00
nntrivi2001 26aabd6660 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.
2026-10-28 11:47:19 +07:00
22 changed files with 1643 additions and 647 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.29 (2026-10-28) — a skill upload refuses a word like "token" only in a data file's name; System Health hides what off-Linux hosts cannot report._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.8.30 (2026-10-28) — the account pool is one board of pools (split/merge by drag), and a Claude plan is read from its tier and seat._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -800,7 +800,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */ /** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) { function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
Boolean Boolean
); );
if (parts.length === 0) return null; if (parts.length === 0) return null;
+152 -285
View File
@@ -1,15 +1,15 @@
/** /**
* @file AccountPoolCard.tsx * @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`), one * @description The Users page's account pool card (`GET /api/accounts`).
* tab per AI tool so each tool's pool is managed on its own: Claude Code, * Claude Code, Codex and Cursor accounts live on the POOLS board
* Codex and Cursor have a pool — the accounts every user's new sessions of that * (`PoolGroups`): one column per pool with its accounts of every tool — each
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and * a card with its live panes (and, for Claude/Codex, $ and tokens in the last
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user; * 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
* Oh My Pi has no pool but can share the host's login (the same switch as * tools with no pool get a tab each below it: Oh My Pi can share the host's
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every * login (the same switch as Settings → AI tools,
* other installed tool says that its users sign in themselves. A tab shows * `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
* how many accounts its pool holds, and an empty pool says so instead of * says that its users sign in themselves. An admin adds an account from the
* vanishing. An admin adds an account to the open tab's tool — for Claude a * header's Add account, picking its tool — for Claude a
* login MOVED in from a saved profile (Settings → AI tools → Profiles, * login MOVED in from a saved profile (Settings → AI tools → Profiles,
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a * `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
* `claude setup-token` token; for Codex a saved Codex profile * `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; * 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 * 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 * 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 * Claude login goes into their home at once — no restart. A profile's
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`, * "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 * which opens Add on that profile; the header links back to the profiles.
* to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { Link, useSearchParams } from "react-router-dom"; 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 { import {
api, api,
type AccountPin, type AccountPin,
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { SettingsSwitchRow } from "../settings/SettingsRow"; import { SettingsSwitchRow } from "../settings/SettingsRow";
import { PoolGroups, groupsOf } from "./PoolGroups"; import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
import { MemberPicker } from "./MemberPicker";
type Row = { type Row = {
tool: PoolTool; tool: PoolTool;
@@ -69,6 +69,8 @@ type Row = {
full: boolean; full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */ /** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[]; 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. */ /** 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). */ /** The tools that have a pool, in tab order (server/routes/accounts.js). */
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"]; const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
/** Fixed tabs, always shown; any other installed tool follows them. */ /** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
const FIXED_TABS: { id: string; label: string }[] = [ const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
{ 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). */ /** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
const METERED = new Set<PoolTool>(["claude", "codex"]); const METERED = new Set<PoolTool>(["claude", "codex"]);
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const profile = params.get("poolProfile"); const profile = params.get("poolProfile");
return tool && isPoolTool(tool) && profile ? { tool, profile } : null; 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. // What Add opens on: a tool, and a saved profile to share into its pool.
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() => const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
isAdmin && preset ? preset : null isAdmin && preset ? preset : null
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const tabs = [ const tabs = [
...FIXED_TABS, ...FIXED_TABS,
...aiTools.tools ...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 })), .map((x) => ({ id: x.id, label: x.label })),
]; ];
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!; const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
const badge = (id: string): string | null => { const badge = (id: string): string | null =>
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null; id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null; const rowOf = (tool: PoolTool, name: string) =>
return null; pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
};
return ( return (
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool"> <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"> <Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")} {t("pool.profilesLink")}
</Link> </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 <button
type="button" type="button"
onClick={refresh} onClick={refresh}
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button> </button>
</div> </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 <div
role="tablist" role="tablist"
aria-label={t("pool.title")} aria-label={t("pool.otherTools")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border" className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
> >
{tabs.map((x) => { {tabs.map((x) => {
const selected = x.id === current.id; const selected = x.id === current.id;
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
); );
})} })}
</div> </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}`}> <div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
{isPoolTool(current.id) ? ( {current.id === "omp" ? (
<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} /> <OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : ( ) : (
<NoPoolPanel id={current.id} label={current.label} /> <NoPoolPanel id={current.id} label={current.label} />
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{adding && ( {adding && (
<AddAccountDialog <AddAccountDialog
tool={adding.tool} 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} preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd} onClose={closeAdd}
onDone={(name) => { 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. */ /** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
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({ function AccountCard({
row: r, row: r,
pool, pool,
grouped,
isAdmin, isAdmin,
onEdit, onEdit,
onRemove, onRemove,
}: { }: {
row: Row; row: Row;
pool: AccountPool; pool: AccountPool;
grouped: boolean;
isAdmin: boolean; isAdmin: boolean;
onEdit: () => void; onEdit: () => void;
onRemove: () => void; onRemove: () => void;
@@ -443,39 +399,52 @@ function AccountCard({
const users = pinsOf(pool, r.tool) const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name) .filter((p) => p.account === r.name)
.map((p) => p.username); .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 ( return (
<div <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" r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`} }`}
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
data-testid="pool-account" data-testid="pool-account"
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div> <div className="flex min-w-0 items-center gap-1.5">
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>} <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> </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 && ( {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 <button
type="button" type="button"
onClick={onEdit} onClick={onEdit}
title={t("pool.edit", { name: r.name })} title={t("pool.edit", { name: r.name })}
aria-label={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>
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
title={t("pool.remove", { name: r.name })} title={t("pool.remove", { name: r.name })}
aria-label={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> </button>
</div> </div>
)} )}
@@ -488,68 +457,47 @@ function AccountCard({
</span> </span>
)} )}
{r.refreshError && !r.expired && ( {r.refreshError && !r.expired && (
<span <span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
{t("pool.refreshFailed")} {t("pool.refreshFailed")}
</span> </span>
)} )}
{r.full && ( {r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
</div> </div>
)} )}
<div className="flex items-center gap-3 text-xs text-fg-secondary"> {metered && (
<span className="inline-flex items-center gap-1"> <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 <span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`} className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden aria-hidden
/> />
{t("pool.liveCount", { count: r.livePanes })} {t("pool.liveCount", { count: r.livePanes })}
</span> </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> </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 && ( {users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary"> <div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden /> <UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })} {t("pool.pinned", { users: users.join(", ") })}
</div> </div>
)} )}
<div className="mt-auto text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
</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="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="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span <span
className={`block h-full ${ className={`block h-full transition-[width] duration-500 ${
left == null left == null
? "" ? ""
: left === 0 : 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 * 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 * 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 changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0; const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) => const groupOf = (userId: number) =>
setChosen((prev) => { (pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const submit = async () => { const submit = async () => {
if (!ready || busy) return; if (!ready || busy) return;
@@ -822,38 +699,28 @@ function EditAccountDialog({
> >
<div> <div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span> <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"> <div className="mt-1">
{users === null && ( <MemberPicker
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p> users={users}
)} chosen={chosen}
{users?.length === 0 && ( onChange={setChosen}
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p> groupOf={groupOf}
)} grouped={(pool.groups ?? []).length > 0}
{users?.map((u) => { note={(u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return ( const group = groupOf(u.id);
<div key={u.id} className="px-3 py-2"> return (
<Checkbox <span className="ml-1.5 text-[11px] text-fg-muted">
checked={chosen.has(u.id)} {[
onChange={(on) => toggle(u.id, on)} group && t("pool.groups.inGroup", { group }),
label={ elsewhere && t("pool.onAccount", { account: elsewhere.account }),
<span> ]
{u.username} .filter(Boolean)
{u.disabled && ( .join(" · ")}
<span className="ml-1 text-fg-muted">({t("disabled")})</span> </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> </div>
</div> </div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}> <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 * @file AccountPoolCard.test.tsx
* @description The Users page's account pool card: one tab per AI tool — the * @description The Users page's account pool card: the pools board with every
* Claude, Codex and Cursor pools with their accounts' live panes and quota, * tool's accounts (live panes and quota) and members per pool, then a tab per
* Oh My Pi's login sharing, and a "no pool" note for any other installed * tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
* tool; says what an empty pool means per tool, an * other installed tool; an
* admin adds an account only with their own password typed (the token goes * 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 * 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 * 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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import type { AccountPool } from "../../../lib/api"; import type { AccountPool } from "../../../lib/api";
@@ -129,6 +134,28 @@ const OMP = {
gateway: { running: false }, 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(() => { beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset(); for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({ aiTools.list.mockResolvedValue({
@@ -147,8 +174,8 @@ beforeEach(() => {
accountPool.list.mockResolvedValue(POOL); accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({ users.list.mockResolvedValue({
users: [ users: [
{ id: 7, username: "mia", disabled: false }, { id: 7, username: "mia", role: "developer", disabled: false },
{ id: 8, username: "leo", disabled: false }, { id: 8, username: "leo", role: "analyst", disabled: false },
], ],
}); });
accountPool.profiles.mockResolvedValue({ accountPool.profiles.mockResolvedValue({
@@ -166,11 +193,15 @@ beforeEach(() => {
}); });
describe("AccountPoolCard", () => { 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 user = userEvent.setup();
const { unmount } = render(card(false)); render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument(); 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"); const [account, second] = screen.getAllByTestId("pool-account");
// Every account of the pool has its card; quota is shown as what is LEFT. // Every account of the pool has its card; quota is shown as what is LEFT.
expect(second).toHaveTextContent("team-2"); 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("$12.00 · 3.4M tokens in 7 days");
expect(account).toHaveTextContent("2 live"); expect(account).toHaveTextContent("2 live");
expect(screen.getByText("team1@example.com")).toBeInTheDocument(); expect(screen.getByText("team1@example.com")).toBeInTheDocument();
// Without groups nothing is said about them on an account. // An analyst reads: no add, remove or new pool.
expect(screen.queryByText("Shared pool")).toBeNull(); expect(screen.queryByTestId("pool-add-account")).toBeNull();
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull(); expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
const codexTab = screen.getByRole("tab", { name: /Codex/ }); expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
expect(codexTab).toHaveTextContent("1"); // Pool tools have no tab any more; an installed tool without a pool does.
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2"); expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
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/ })); await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument(); expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
expect(screen.getByText(/system keyring/)).toBeInTheDocument(); expect(screen.getByText(/system keyring/)).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull(); 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.add.mockResolvedValue({ account: { name: "cu-1" } });
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] }); accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
const user = userEvent.setup(); const user = userEvent.setup();
render(card(true)); render(card(true));
await screen.findByText("team-1"); await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ })); await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(screen.queryByLabelText("From")).toBeNull(); expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1"); await user.type(screen.getByLabelText("Name"), "cu-1");
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor"); expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
}); });
it("shares a saved profile from its card", async () => { it("builds pools on the board: members, accounts and profiles moved in, one password", 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({ accountPool.list.mockResolvedValue({
...POOL, ...POOL,
groups: [ groups: [
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
}); });
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } }); accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
accountPool.updateGroup.mockResolvedValue({ group: {} }); accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup(); const user = userEvent.setup();
render(card(true)); render(card(true));
const groupCard = await screen.findByTestId("pool-group"); const teamA = await screen.findByTestId("pool-group");
expect(groupCard).toHaveTextContent("Team A"); expect(teamA).toHaveTextContent("leo");
expect(groupCard).toHaveTextContent("leo"); expect(teamA).toHaveTextContent("team-1");
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A"); await screen.findByText("mia");
await user.click(screen.getByRole("button", { name: "New group" }));
await screen.findByRole("dialog"); await user.click(screen.getByRole("button", { name: "New pool" }));
await user.type(screen.getByLabelText("Group name"), "Team B"); const names = screen.getAllByLabelText("Pool name");
await user.click(await screen.findByRole("checkbox", { name: /leo/ })); await user.type(names[names.length - 1]!, "Team B");
expect(screen.getByText("now in Team A")).toBeInTheDocument(); const teamB = screen.getAllByTestId("pool-group")[1]!;
await user.click(screen.getByRole("checkbox", { name: "cx-1" })); drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
await user.type(screen.getByLabelText("Your password"), "boss-pass"); expect(teamB).toHaveTextContent("mia");
await user.click(screen.getByRole("button", { name: "Save" })); 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(() => await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith( expect(accountPool.updateGroup).toHaveBeenCalledWith(
2, 2,
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } }, { userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
"boss-pass" "boss-pass"
) )
); );
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "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 () => { it("turns Oh My Pi login sharing on from its tab", async () => {
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(card(true)); render(card(true));
await screen.findByText("team-1"); 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 dialog = await screen.findByRole("dialog");
const select = await screen.findByLabelText("Profile"); const select = await screen.findByLabelText("Profile");
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(card(true)); render(card(true));
await screen.findByText("team-1"); 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 dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token"); await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2"); await user.type(screen.getByLabelText("Name"), "team-2");
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(card(true)); render(card(true));
await screen.findByText("team-1"); 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.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass"); await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" })); await user.click(screen.getByRole("button", { name: "Remove" }));
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
render(card(true, "/admin?poolAdd=codex&poolProfile=cx")); render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex")); 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 waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass"); await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find( const confirm = Array.from(dialog.querySelectorAll("button")).find(
+59 -9
View File
@@ -133,7 +133,7 @@
}, },
"pool": { "pool": {
"title": "Account pool", "title": "Account pool",
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.", "description": "Each pool holds accounts of every AI tool (Claude Code, Codex, Cursor) and the members who run on them. A member's new session takes the least-used account of their pool, so nobody signs in per user.",
"refresh": "Re-read quotas", "refresh": "Re-read quotas",
"add": "Add account", "add": "Add account",
"account": "Account", "account": "Account",
@@ -254,20 +254,20 @@
"shareNamed": "Share {{name}} to the pool" "shareNamed": "Share {{name}} to the pool"
}, },
"groups": { "groups": {
"title": "Groups", "title": "Pools",
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.", "description": "Members in no pool, and accounts in no pool, make up the shared pool.",
"new": "New group", "new": "New pool",
"empty": "No groups yet — every user shares every account.", "empty": "No groups yet — every user shares every account.",
"members_one": "{{count}} member", "members_one": "{{count}} member",
"members_other": "{{count}} members", "members_other": "{{count}} members",
"edit": "Edit group {{name}}", "edit": "Edit group {{name}}",
"delete": "Delete group {{name}}", "delete": "Delete pool {{name}}",
"deleteHint": "Its members go back to the shared pool. No account is removed.", "deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
"noAccounts": "none — members sign in themselves", "noAccounts": "none — members sign in themselves",
"saved": "Group {{name}} saved", "saved": "Group {{name}} saved",
"deleted": "Group {{name}} deleted", "deleted": "Pool {{name}} deleted",
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.", "hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
"name": "Group name", "name": "Pool name",
"memberList": "Members", "memberList": "Members",
"inGroup": "now in {{group}}", "inGroup": "now in {{group}}",
"accountList": "Accounts", "accountList": "Accounts",
@@ -278,7 +278,57 @@
"5h": "5 hours", "5h": "5 hours",
"wk": "Week" "wk": "Week"
}, },
"resetsIn": "resets in {{time}} · {{at}}" "resetsIn": "resets in {{time}} · {{at}}",
"members": {
"title": "Members",
"filter": "Show members",
"all": "All",
"noGroup": "No pool",
"search": "Search members",
"tickShown": "Tick all {{count}}",
"noneShown": "No member matches."
},
"board": {
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
"sharedHint": "Members and accounts in no pool",
"toShared": "Send {{name}} back to the shared pool",
"removeAccount": "Send {{name}} back to the shared pool",
"moveHere_one": "Move {{count}} here",
"moveHere_other": "Move {{count}} here",
"noMembers": "No members",
"noSharedAccounts": "No account is left for the shared pool.",
"dropMembers": "Drop members here",
"untitled": "New pool",
"new": "not saved yet",
"pick": "Pick members of {{name}}",
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
"apply": "Apply",
"addAccount": "Move an account into {{name}}",
"addAccountOption": "+ Move an account or saved profile here…",
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
"profilePending": "A saved profile: it is moved into the pool when you save",
"pending_one": "{{count}} unsaved change",
"pending_other": "{{count}} unsaved changes",
"needsName": "Every pool needs a name.",
"discard": "Discard",
"saveTitle": "Save the pools",
"saveHint": "Type your own password once to apply every change below.",
"summaryProfile": "Move the saved profile {{name}} into the pool",
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
"saved": "Pools saved",
"dropAccounts": "Drag accounts here",
"noAccounts": "No accounts",
"accountsCount_one": "{{count}} account",
"accountsCount_other": "{{count}} accounts",
"mergeHint": "Drop to merge {{from}} into {{into}}",
"merged": "{{from}} merged into {{into}} — Save to apply",
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
"newDrop": "Drop here to split it into a pool of its own",
"newHint": "Or drag an account or a member here to split it off"
},
"addAccount": "Add account",
"otherTools": "Other AI tools"
}, },
"delete": { "delete": {
"button": "Delete user", "button": "Delete user",
+59 -9
View File
@@ -133,7 +133,7 @@
}, },
"pool": { "pool": {
"title": "Pool tài khoản", "title": "Pool tài khoản",
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.", "description": "Mỗi pool chứa tài khoản của mọi AI tool (Claude Code, Codex, Cursor) và các member dùng chúng. Session mới của member chạy trên tài khoản dùng ít nhất trong pool của họ, không ai phải tự đăng nhập.",
"refresh": "Đọc lại quota", "refresh": "Đọc lại quota",
"add": "Thêm tài khoản", "add": "Thêm tài khoản",
"account": "Tài khoản", "account": "Tài khoản",
@@ -254,20 +254,20 @@
"shareNamed": "Chia sẻ {{name}} vào pool" "shareNamed": "Chia sẻ {{name}} vào pool"
}, },
"groups": { "groups": {
"title": "Nhóm", "title": "Pool",
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.", "description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.",
"new": "Tạo nhóm", "new": "Tạo pool",
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.", "empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
"members_one": "{{count}} thành viên", "members_one": "{{count}} thành viên",
"members_other": "{{count}} thành viên", "members_other": "{{count}} thành viên",
"edit": "Sửa nhóm {{name}}", "edit": "Sửa nhóm {{name}}",
"delete": "Xoá nhóm {{name}}", "delete": "Xoá pool {{name}}",
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.", "deleteHint": "Member và tài khoản của pool quay về pool chung. Không tài khoản nào bị xoá.",
"noAccounts": "chưa có — thành viên tự đăng nhập", "noAccounts": "chưa có — thành viên tự đăng nhập",
"saved": "Đã lưu nhóm {{name}}", "saved": "Đã lưu nhóm {{name}}",
"deleted": "Đã xoá nhóm {{name}}", "deleted": "Đã xoá pool {{name}}",
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.", "hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
"name": "Tên nhóm", "name": "Tên pool",
"memberList": "Thành viên", "memberList": "Thành viên",
"inGroup": "đang ở {{group}}", "inGroup": "đang ở {{group}}",
"accountList": "Tài khoản", "accountList": "Tài khoản",
@@ -278,7 +278,57 @@
"5h": "5 giờ", "5h": "5 giờ",
"wk": "Tuần" "wk": "Tuần"
}, },
"resetsIn": "reset sau {{time}} · {{at}}" "resetsIn": "reset sau {{time}} · {{at}}",
"members": {
"title": "Thành viên",
"filter": "Hiện thành viên",
"all": "Tất cả",
"noGroup": "Chưa có pool",
"search": "Tìm thành viên",
"tickShown": "Chọn cả {{count}}",
"noneShown": "Không có thành viên nào khớp."
},
"board": {
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
"toShared": "Đưa {{name}} về pool chung",
"removeAccount": "Đưa {{name}} về pool chung",
"moveHere_one": "Chuyển {{count}} vào đây",
"moveHere_other": "Chuyển {{count}} vào đây",
"noMembers": "Chưa có thành viên",
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
"dropMembers": "Thả thành viên vào đây",
"untitled": "Pool mới",
"new": "chưa lưu",
"pick": "Chọn thành viên cho {{name}}",
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
"apply": "Áp dụng",
"addAccount": "Chuyển tài khoản vào {{name}}",
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
"pending_one": "{{count}} thay đổi chưa lưu",
"pending_other": "{{count}} thay đổi chưa lưu",
"needsName": "Pool nào cũng cần có tên.",
"discard": "Bỏ thay đổi",
"saveTitle": "Lưu các pool",
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
"summaryProfile": "Chuyển profile {{name}} vào pool",
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
"saved": "Đã lưu các pool",
"dropAccounts": "Kéo tài khoản vào đây",
"noAccounts": "Chưa có tài khoản",
"accountsCount_one": "{{count}} tài khoản",
"accountsCount_other": "{{count}} tài khoản",
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
"newDrop": "Thả vào đây để tách thành pool riêng",
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
},
"addAccount": "Thêm tài khoản",
"otherTools": "AI tool khác"
}, },
"delete": { "delete": {
"button": "Xoá user", "button": "Xoá user",
+6
View File
@@ -810,6 +810,8 @@ export type PoolAccount = {
} | null; } | null;
quotaPercent: number | null; quotaPercent: number | null;
full: boolean; full: boolean;
/** Readable plan of a login ("Max 5x", "Team Premium"); null for a setup-token. */
plan?: string | null;
}; };
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */ /** One Codex pool account — an API key or a ChatGPT login, never the secret. */
export type CodexPoolAccount = { export type CodexPoolAccount = {
@@ -828,6 +830,8 @@ export type CodexPoolAccount = {
} | null; } | null;
quotaPercent: number | null; quotaPercent: number | null;
full: boolean; full: boolean;
/** A ChatGPT login's plan ("Plus", "Pro", "Team"); null for an API key. */
plan?: string | null;
}; };
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */ /** A user an admin pinned to one pool account: their new sessions skip the balancer. */
export type AccountPin = { export type AccountPin = {
@@ -4778,6 +4782,8 @@ export interface CcAccountIdentity {
organization: string | null; organization: string | null;
seatTier: string | null; seatTier: string | null;
subscriptionType: string | null; subscriptionType: string | null;
/** Readable plan from tier + seat ("Max 5x", "Team Premium"); prefer it. */
plan?: string | null;
/** Access-token expiry, epoch ms; a switch still works past it (the refresh /** Access-token expiry, epoch ms; a switch still works past it (the refresh
* token is stored too). */ * token is stored too). */
expiresAt: number | null; expiresAt: number | null;
+3 -1
View File
@@ -228,6 +228,8 @@ describe("Users page", () => {
expect(confirm.hasAttribute("disabled")).toBe(true); expect(confirm.hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass"); await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(confirm.hasAttribute("disabled")).toBe(false); expect(confirm.hasAttribute("disabled")).toBe(false);
// The pool board reads the user list too; count the page's own reload.
const before = users.list.mock.calls.length;
await ue.click(confirm); await ue.click(confirm);
await waitFor(() => await waitFor(() =>
expect(users.create).toHaveBeenCalledWith( expect(users.create).toHaveBeenCalledWith(
@@ -236,7 +238,7 @@ describe("Users page", () => {
) )
); );
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
}); });
it("reports a wrong password in the edit dialog and keeps it open", async () => { it("reports a wrong password in the edit dialog and keeps it open", async () => {
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.29 (rebuild client to match) UI build v2.8.30 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+3 -3
View File
@@ -95,7 +95,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes. An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`): **The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
@@ -2584,7 +2584,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1] GET /api/pricing/quota[?force=1]
``` ```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
#### Analytics #### Analytics
```http ```http
@@ -2900,7 +2900,7 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file. **Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `plan` — the readable plan from tier + seat —, `expiresAt`).
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before. **Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
+23 -1
View File
@@ -595,7 +595,7 @@ agenthub accounts pin cursor-team mia --tool cursor
## Account pool groups ## Account pool groups
By default every user shares every pool account. **Groups** split the pool By default every user shares every pool account. **Groups** split the pool
by team (Users page → **Account pool → Groups**, server mode only, by team (Users page → **Account pool**, where a group is called a pool, server mode only,
`server/lib/pool-groups.js`): `server/lib/pool-groups.js`):
- A group has a name, members and accounts of every pool tool (Claude, Codex, - A group has a name, members and accounts of every pool tool (Claude, Codex,
@@ -613,6 +613,28 @@ by team (Users page → **Account pool → Groups**, server mode only,
an account takes it out of every group; deleting a group sends its members an account takes it out of every group; deleting a group sends its members
back to the shared pool and removes no account. back to the shared pool and removes no account.
On the Users page a group is shown as a **pool**: the **Account pool** card
is one board with a column for the shared pool and one per pool, each
holding its accounts of every tool (grouped by tool, each a card with its
plan — "Max 5x", "Team Premium", a ChatGPT plan —, live panes and quota) and
its members. Drag an account or a member from one
column to another — in the UI an account sits in ONE pool, so a drop moves
it (onto the shared pool takes it off every pool). Dropping one on the
trailing **New pool** column SPLITS it off into a new pool of its own;
dragging a pool's ONLY account into another column MERGES that pool there —
its members come along and the emptied pool is deleted on save. A column's **+ Move an
account or saved profile here** menu does the same without dragging and also
lists the saved login profiles, which are moved into the pool on save; **Pick
members** ticks members by role (Admin / Developer / Analyst) or **No pool**
in a dialog; **Add account** in the card's header adds a new account for a
chosen tool (it lands in the shared pool). Nothing is sent until **Save**,
which asks for the password once and applies every change (profiles moved
in, then new pools created, then each changed pool set exactly, then merged
pools deleted); a failed
step keeps the board so a retry resumes where it stopped. Tools with no pool
(Oh My Pi, any other installed tool) keep a tab each under **Other AI
tools**. An account's Edit (pencil) uses the same role / No pool list.
### A user's own pool account ### A user's own pool account
When the pool holds more than one account a user may use, they pick which one When the pool holds more than one account a user may use, they pick which one
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.29", "version": "2.8.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.29", "version": "2.8.30",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.29", "version": "2.8.30",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+18
View File
@@ -444,3 +444,21 @@ describe("cc-profiles — identity and token disagree", () => {
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" }); assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
}); });
}); });
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
const { planLabel } = profiles;
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
assert.strictEqual(planLabel(team, { seatTier: "team_premium" }), "Team Premium");
// `team_tier_1` names no kind; a Max-sized limit is what a premium seat gives.
assert.strictEqual(
planLabel(team, { seatTier: "team_tier_1", userRateLimitTier: "default_claude_max_5x" }),
"Team Premium"
);
assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null);
});
+1 -1
View File
@@ -222,7 +222,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
assert.strictEqual(live.label, "Victor"); assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y"); assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined); assert.strictEqual(live.ownerUuid, undefined);
assert.strictEqual(live.plan, "max"); assert.strictEqual(live.plan, "Max");
assert.deepStrictEqual( assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label), claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"] ["Thắng"]
+44
View File
@@ -179,6 +179,47 @@ function readActiveAuth() {
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain }; return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
} }
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
/**
* The plan's name as the account's owner knows it. `subscriptionType` alone is
* too coarse — it says `team` for both seat kinds, and it is written at sign-in,
* so an account upgraded from Pro to Max keeps saying `pro` — while the rate
* limit tier (`default_claude_max_5x`) and the seat (`team_standard`) carry the
* real one. "Max 5x", "Team Premium", "Team Standard", else the type itself.
*/
function planLabel(oauth, account) {
const sub = String(oauth?.subscriptionType || "").toLowerCase();
// The token's tier, else the user's own: on a Team account the token may
// carry the organization's tier while the member's seat is the larger one.
const tier = `${oauth?.rateLimitTier || ""} ${account?.userRateLimitTier || ""}`;
const seat = String(account?.seatTier || "").toLowerCase();
const max = /max_(\d+)x/i.exec(tier);
const org = String(account?.organizationType || "");
const kind =
sub === "team" || sub === "enterprise"
? sub
: /team/.test(org) || seat.startsWith("team")
? "team"
: /enterprise/.test(org)
? "enterprise"
: null;
if (kind) {
// A premium seat lifts the member onto a Max-sized limit; the seat name
// says so for `team_premium`, the tier for `team_tier_1`.
const level = /premium/.test(seat)
? "Premium"
: /standard/.test(seat)
? "Standard"
: max
? "Premium"
: null;
return [cap(kind), level].filter(Boolean).join(" ");
}
if (max) return `Max ${max[1]}x`;
return sub ? cap(sub) : null;
}
/** Token-free description of an account, safe to send to the browser. */ /** Token-free description of an account, safe to send to the browser. */
function identityOf({ oauth, account }) { function identityOf({ oauth, account }) {
return { return {
@@ -188,6 +229,8 @@ function identityOf({ oauth, account }) {
organization: (account && account.organizationName) || null, organization: (account && account.organizationName) || null,
seatTier: (account && account.seatTier) || null, seatTier: (account && account.seatTier) || null,
subscriptionType: (oauth && oauth.subscriptionType) || null, subscriptionType: (oauth && oauth.subscriptionType) || null,
/** The readable plan (`planLabel`) — what the UI shows. */
plan: planLabel(oauth, account),
/** When the stored access token expires; a switch still works after that /** When the stored access token expires; a switch still works after that
* (the refresh token is stored too), it just refreshes on first use. */ * (the refresh token is stored too), it just refreshes on first use. */
expiresAt: (oauth && oauth.expiresAt) || null, expiresAt: (oauth && oauth.expiresAt) || null,
@@ -544,6 +587,7 @@ module.exports = {
writeProfile, writeProfile,
liveCredentialsFor, liveCredentialsFor,
readActiveAuth, readActiveAuth,
planLabel,
// exported for tests // exported for tests
profilesDir, profilesDir,
configPaths, configPaths,
+2
View File
@@ -435,6 +435,8 @@ function poolStatus() {
name: a.name, name: a.name,
kind: a.kind, kind: a.kind,
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null, email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null, refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt, addedAt: a.addedAt,
expiresAt: expiresAt(a), expiresAt: expiresAt(a),
+14
View File
@@ -245,6 +245,7 @@ function poolStatus() {
return { return {
name: a.name, name: a.name,
kind: a.kind, kind: a.kind,
plan: a.kind === "chatgpt" ? planOf(a.auth) : null,
addedAt: a.addedAt, addedAt: a.addedAt,
livePanes: livePanes(a.name), livePanes: livePanes(a.name),
...usageOf(a.name), ...usageOf(a.name),
@@ -285,6 +286,19 @@ function identity(auth) {
} }
} }
/** The ChatGPT plan its id_token names, title-cased ("Plus", "Pro", "Team"), or null. */
function planOf(auth) {
try {
const c = JSON.parse(
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
);
const p = c[AUTH_CLAIM]?.chatgpt_plan_type;
return typeof p === "string" && p ? p.charAt(0).toUpperCase() + p.slice(1) : null;
} catch {
return null;
}
}
/** /**
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub * Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later * placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
+4 -4
View File
@@ -36,7 +36,7 @@
* `AGENTHUB_AGY_USAGE=0` never runs it. * `AGENTHUB_AGY_USAGE=0` never runs it.
* *
* Each tool also carries its signed-in `account` (an email), its `plan` (the * Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name as the tool reports it: Claude's `subscriptionType`, * subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and * Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
* `expiresAt`, when its stored access token runs out — Claude's * `expiresAt`, when its stored access token runs out — Claude's
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims * `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
const { promisify } = require("node:util"); const { promisify } = require("node:util");
const aiTools = require("./ai-tools"); const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home"); const { getCodexHome } = require("./codex-home");
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles"); const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage"); const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest"); const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage"); const { getCursorDir } = require("./cursor-usage");
@@ -142,7 +142,7 @@ async function liveClaude(readAuth = readActiveAuth) {
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = { who = {
account: account?.emailAddress || account?.displayName || null, account: account?.emailAddress || account?.displayName || null,
plan: oauth.subscriptionType || null, plan: planLabel(oauth, account),
expiresAt: iso(oauth.expiresAt), expiresAt: iso(oauth.expiresAt),
}; };
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" }; if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
@@ -172,7 +172,7 @@ async function liveClaude(readAuth = readActiveAuth) {
async function profileClaude(item) { async function profileClaude(item) {
const who = { const who = {
account: item.email || item.displayName || null, account: item.email || item.displayName || null,
plan: item.subscriptionType || null, plan: item.plan || item.subscriptionType || null,
expiresAt: iso(item.expiresAt), expiresAt: iso(item.expiresAt),
}; };
let creds = null; let creds = null;
+1 -1
View File
@@ -88,7 +88,7 @@ router.get("/profiles", (req, res) => {
name: p.name, name: p.name,
label: p.label, label: p.label,
email: p.email, email: p.email,
subscriptionType: p.subscriptionType, subscriptionType: p.plan || p.subscriptionType,
active: p.active, active: p.active,
})), })),
}); });