diff --git a/client/src/components/settings/ToolProfilesSettings.tsx b/client/src/components/settings/ToolProfilesSettings.tsx index c087c4ea..7f76b1b0 100644 --- a/client/src/components/settings/ToolProfilesSettings.tsx +++ b/client/src/components/settings/ToolProfilesSettings.tsx @@ -800,7 +800,7 @@ function ProfileUsageLine({ /** Org / seat / plan line shared by the active card and each profile row. */ function IdentityLine({ identity }: { identity: CcAccountIdentity }) { - const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( + const parts = [identity.organization, identity.plan || identity.subscriptionType].filter( Boolean ); if (parts.length === 0) return null; diff --git a/client/src/components/users/AccountPoolCard.tsx b/client/src/components/users/AccountPoolCard.tsx index 27eec891..6b3a5786 100644 --- a/client/src/components/users/AccountPoolCard.tsx +++ b/client/src/components/users/AccountPoolCard.tsx @@ -1,15 +1,15 @@ /** * @file AccountPoolCard.tsx - * @description The Users page's account pool card (`GET /api/accounts`), one - * tab per AI tool so each tool's pool is managed on its own: Claude Code, - * Codex and Cursor have a pool — the accounts every user's new sessions of that - * tool are spread over, each with its live panes (and, for Claude/Codex, $ and - * tokens in the last 5 h / 7 d and quota) — so nobody signs in per user; - * Oh My Pi has no pool but can share the host's login (the same switch as - * Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every - * other installed tool says that its users sign in themselves. A tab shows - * how many accounts its pool holds, and an empty pool says so instead of - * vanishing. An admin adds an account to the open tab's tool — for Claude a + * @description The Users page's account pool card (`GET /api/accounts`). + * Claude Code, Codex and Cursor accounts live on the POOLS board + * (`PoolGroups`): one column per pool with its accounts of every tool — each + * a card with its live panes (and, for Claude/Codex, $ and tokens in the last + * 5 h / 7 d and quota) — and its members, so nobody signs in per user. The + * tools with no pool get a tab each below it: Oh My Pi can share the host's + * login (the same switch as Settings → AI tools, + * `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool + * says that its users sign in themselves. An admin adds an account from the + * header's Add account, picking its tool — for Claude a * login MOVED in from a saved profile (Settings → AI tools → Profiles, * `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a * `claude setup-token` token; for Codex a saved Codex profile @@ -17,11 +17,11 @@ * Cursor API key — or removes one, each time with their own password again; * an analyst only reads. The secret only goes IN: the server never sends it * back. An account's Edit (pencil) picks the users who run on it - * (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a + * (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role + * or "no group"): their sessions skip the balancer, and a * Claude login goes into their home at once — no restart. A profile's * "Move to pool" link lands here as `?poolAdd=&poolProfile=`, - * which opens that tool's tab and Add on that profile; the header links back - * to the profiles. + * which opens Add on that profile; the header links back to the profiles. * @author Nguyễn Ngọc Trí Vĩ */ @@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Link, useSearchParams } from "react-router-dom"; -import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react"; +import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react"; import { api, type AccountPin, @@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys"; import { useSpin } from "../../hooks/useSpin"; import { useAiTools } from "../../hooks/useAiTools"; import { ConfirmModal } from "../ConfirmModal"; -import { Checkbox } from "../Checkbox"; import { AiToolIcon } from "../AiToolIcon"; import { SettingsSwitchRow } from "../settings/SettingsRow"; -import { PoolGroups, groupsOf } from "./PoolGroups"; +import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups"; +import { MemberPicker } from "./MemberPicker"; type Row = { tool: PoolTool; @@ -69,6 +69,8 @@ type Row = { full: boolean; /** The 5-hour and weekly windows, as the account last reported them. */ windows: QuotaWindow[]; + /** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */ + plan?: string | null; }; /** One quota window of an account: % used and when it rolls over. */ @@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null { /** The tools that have a pool, in tab order (server/routes/accounts.js). */ const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"]; -/** Fixed tabs, always shown; any other installed tool follows them. */ -const FIXED_TABS: { id: string; label: string }[] = [ - { id: "claude", label: "Claude Code" }, - { id: "codex", label: "Codex" }, - { id: "cursor", label: "Cursor CLI" }, - { id: "omp", label: "Oh My Pi" }, -]; +/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */ +const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }]; /** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */ const METERED = new Set(["claude", "codex"]); @@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { const profile = params.get("poolProfile"); return tool && isPoolTool(tool) && profile ? { tool, profile } : null; })(); - const [tab, setTab] = useState(preset?.tool ?? "claude"); + const [tab, setTab] = useState("omp"); // What Add opens on: a tool, and a saved profile to share into its pool. const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() => isAdmin && preset ? preset : null @@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { const tabs = [ ...FIXED_TABS, ...aiTools.tools - .filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id)) + .filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id)) .map((x) => ({ id: x.id, label: x.label })), ]; const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!; - const badge = (id: string): string | null => { - if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null; - if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null; - return null; - }; + const badge = (id: string): string | null => + id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null; + const rowOf = (tool: PoolTool, name: string) => + pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined; return (
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { {t("pool.profilesLink")} + {isAdmin && ( + + )} - {pool?.groups && void load()} />} + {error && ( +

+ {error} +

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

+ {t("pool.otherTools")} +

{tabs.map((x) => { const selected = x.id === current.id; @@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { ); })}
- {error && ( -

- {error} -

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

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

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

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

- {addButton} -
- )} - {rows.length > 0 && ( -
- {rows.map((r) => ( - onEdit(r)} - onRemove={() => onRemove(r)} - /> - ))} -
- )} - {isAdmin && SOURCES[tool].includes("profile") && ( - onAdd(name)} /> - )} - - ); -} - -/** One pool account: who it is, how loaded, which groups and users run on it. */ +/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */ function AccountCard({ row: r, pool, - grouped, isAdmin, onEdit, onRemove, }: { row: Row; pool: AccountPool; - grouped: boolean; isAdmin: boolean; onEdit: () => void; onRemove: () => void; @@ -443,39 +399,52 @@ function AccountCard({ const users = pinsOf(pool, r.tool) .filter((p) => p.account === r.name) .map((p) => p.username); - const inGroups = groupsOf(pool, r.tool, r.name); + const warn = "bg-status-warning/10 text-status-warning border-status-warning/20"; return (
-
{r.name}
- {r.email &&
{r.email}
} +
+ {r.name} + {r.plan && ( + + {r.plan} + + )} +
+ {r.email &&
{r.email}
}
- {t(`pool.kindShort.${r.kind}`)} + + {t(`pool.kindShort.${r.kind}`)} + {isAdmin && ( -
+
)} @@ -488,68 +457,47 @@ function AccountCard({ )} {r.refreshError && !r.expired && ( - + {t("pool.refreshFailed")} )} - {r.full && ( - - {t("pool.full")} - - )} + {r.full && {t("pool.full")}}
)} -
- + {metered && ( +
+ {r.windows.map((w) => ( + + ))} +
+ )} +
+ 0 ? "bg-status-success" : "bg-fg-muted/40"}`} aria-hidden /> {t("pool.liveCount", { count: r.livePanes })} + {metered && ( + <> + · + + {t("pool.spend")}: {fmtCost(r.spend5h)} ·{" "} + {t("pool.tokens", { tokens: fmt(r.tokens5h) })} + + + {t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })} + + + )}
- {metered && ( -
- {r.windows.map((w) => ( - - ))} -
- )} - {metered && ( -
-
- {t("pool.spend")}: {fmtCost(r.spend5h)} ·{" "} - {t("pool.tokens", { tokens: fmt(r.tokens5h) })} -
-
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
-
- )} - {grouped && ( -
- - {inGroups.length === 0 ? ( - {t("pool.groups.shared")} - ) : ( - inGroups.map((g) => ( - - {g.name} - - )) - )} -
- )} {users.length > 0 && (
{t("pool.pinned", { users: users.join(", ") })}
)} -
- {t("pool.added", { at: formatDateTime(r.addedAt) })} -
); } @@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) { {t(`pool.window.${w.key}`)} void; -}) { - const { t } = useTranslation("users"); - const [profiles, setProfiles] = useState(null); - useEffect(() => { - api.accountPool - .profiles(tool) - .then((r) => setProfiles(r.profiles)) - .catch(() => setProfiles([])); - }, [tool, pool]); - return ( -
-
-

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

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

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

- )} - {profiles && profiles.length > 0 && ( -
- {profiles.map((p) => ( -
-
-
{p.label}
-
- {[p.email, p.subscriptionType].filter(Boolean).join(" · ")} - {p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""} -
-
- -
- ))} -
- )} -
- ); -} - /** * Oh My Pi's tab: no pool, but the host's own omp login can be shared with * every member's pane through omp's gateway (server/lib/omp-share.js) — the @@ -777,13 +659,8 @@ function EditAccountDialog({ const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id)); const ready = changed && current.length > 0; - const toggle = (id: number, on: boolean) => - setChosen((prev) => { - const next = new Set(prev); - if (on) next.add(id); - else next.delete(id); - return next; - }); + const groupOf = (userId: number) => + (pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null; const submit = async () => { if (!ready || busy) return; @@ -822,38 +699,28 @@ function EditAccountDialog({ >
{t("pool.users")} -
- {users === null && ( -

{t("pool.loadingUsers")}

- )} - {users?.length === 0 && ( -

{t("pool.noUsers")}

- )} - {users?.map((u) => { - const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); - return ( -
- toggle(u.id, on)} - label={ - - {u.username} - {u.disabled && ( - ({t("disabled")}) - )} - {elsewhere && ( - - {t("pool.onAccount", { account: elsewhere.account })} - - )} - - } - labelClassName="text-sm text-fg-primary" - /> -
- ); - })} +
+ 0} + note={(u) => { + const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); + const group = groupOf(u.id); + return ( + + {[ + group && t("pool.groups.inGroup", { group }), + elsewhere && t("pool.onAccount", { account: elsewhere.account }), + ] + .filter(Boolean) + .join(" · ")} + + ); + }} + />
diff --git a/client/src/components/users/MemberPicker.tsx b/client/src/components/users/MemberPicker.tsx new file mode 100644 index 00000000..8869ab78 --- /dev/null +++ b/client/src/components/users/MemberPicker.tsx @@ -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ĩ + */ + +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, + 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[]; + 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 ( +
+ {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 ( + + ); + })} +
+ ); +} + +export function MemberPicker({ + users, + chosen, + onChange, + groupOf, + grouped, + note, +}: { + users: ServerUserUsage[] | null; + chosen: Set; + onChange: (next: Set) => 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("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 ( +
+ {users && users.length > 0 && ( + <> + +
+ + + setMany( + shown.map((u) => u.id), + on + ) + } + label={t("pool.members.tickShown", { count: shown.length })} + labelClassName="text-[11px] text-fg-secondary" + /> +
+ + )} +
+ {users === null && ( +

{t("pool.loadingUsers")}

+ )} + {users?.length === 0 && ( +

{t("pool.noUsers")}

+ )} + {users && users.length > 0 && shown.length === 0 && ( +

{t("pool.members.noneShown")}

+ )} + {shown.map((u) => ( +
+ setMany([u.id], on)} + label={ + + {u.username} + {u.disabled && ({t("disabled")})} + {note?.(u)} + + } + className="min-w-0 flex-1" + labelClassName="text-sm text-fg-primary" + /> + {t(`roles.${u.role}`)} +
+ ))} +
+
+ ); +} diff --git a/client/src/components/users/PoolGroups.tsx b/client/src/components/users/PoolGroups.tsx index 3b531ab0..669ebcc2 100644 --- a/client/src/components/users/PoolGroups.tsx +++ b/client/src/components/users/PoolGroups.tsx @@ -1,39 +1,58 @@ /** * @file PoolGroups.tsx - * @description The account pool's GROUPS (`/api/accounts/groups`, - * server/lib/pool-groups.js), shown as cards above the tool tabs: each team's - * members run only on the accounts its group lists, of every tool, while a - * user in no group runs on the shared pool (the accounts no group lists). A - * card shows the group's members and its accounts per tool; an admin creates, - * edits (name, members — one group per user, so ticking one moves them here — - * and accounts) or deletes one, each time with their own password again. - * `groupsOf` tells an account card which groups use it. + * @description The account POOLS board (`/api/accounts/groups`, + * server/lib/pool-groups.js — a pool is a group on the server): one column per + * pool holding its accounts of every AI tool (each drawn by the caller, with + * its plan and quota) and its members, plus the shared pool — the members in + * no pool and the accounts no pool lists. An admin drags a member or an + * account from one column to another (an account sits in ONE pool, so a drop + * moves it); drops one on the trailing New pool column to SPLIT it into a new + * pool; and drops a pool's ONLY account into another column to MERGE that pool + * there (its members come along and the emptied pool is deleted on save). A + * column's menu moves an account — or a saved login profile, moved into the + * pool on save — in without dragging; Pick members ticks members by role; a + * pool is renamed in place. Nothing is sent until Save, which asks for the + * admin's own password ONCE and applies every change: profiles moved in + * (`POST /api/accounts`), new pools created, each changed pool set exactly + * (`PATCH`), merged pools deleted (`DELETE`). The trash on a saved pool + * deletes it on its own. * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { Pencil, Plus, Trash2, Users } from "lucide-react"; +import { Combine, Plus, Trash2, UserPlus, X } from "lucide-react"; import { api, type AccountPool, type PoolGroup, + type PoolProfile, type PoolTool, type ServerUserUsage, } from "../../lib/api"; import { isSubmitEnter } from "../../lib/keys"; import { toast } from "../../lib/toast"; import { ConfirmModal } from "../ConfirmModal"; -import { Checkbox } from "../Checkbox"; import { AiToolIcon } from "../AiToolIcon"; +import { MemberPicker } from "./MemberPicker"; -/** Pool tools with their tab labels, in tab order. */ +/** Pool tools with their labels, in display order. */ export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [ { id: "claude", label: "Claude Code" }, { id: "codex", label: "Codex" }, { id: "cursor", label: "Cursor CLI" }, ]; +const TOOLS = POOL_TOOL_LABELS.map((x) => x.id); +const toolLabel = (tool: PoolTool) => POOL_TOOL_LABELS.find((x) => x.id === tool)!.label; +/** Tools whose saved login profiles can be moved into the pool. */ +const PROFILE_TOOLS: PoolTool[] = ["claude", "codex"]; +/** A pool's account entry that is a saved profile still to be moved in. */ +const PROFILE = "profile:"; +const DND = "application/x-agenthub-pool"; +/** Drop-zone keys of the shared pool and of the trailing New pool column. */ +const SHARED = "shared"; +const NEW = "new"; /** Account names of `tool` in the pool. */ function accountNames(pool: AccountPool, tool: PoolTool): string[] { @@ -41,11 +60,6 @@ function accountNames(pool: AccountPool, tool: PoolTool): string[] { return (list ?? []).map((a) => a.name); } -/** The groups that list account `name` of `tool`. */ -export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] { - return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name)); -} - function errorText(t: TFunction<"users">, e: unknown): string { const err = e as Error & { code?: string }; return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" }) @@ -53,109 +67,675 @@ function errorText(t: TFunction<"users">, e: unknown): string { : err.message; } +/** A pool as the board edits it; `id` null = not created yet. */ +type Draft = { + key: string; + id: number | null; + name: string; + userIds: number[]; + accounts: Record; +}; +/** A saved pool merged into another: deleted on Save. */ +type Removed = { id: number; name: string; into: string }; +/** What is being dragged; `from` = the column key it left (null = shared). */ +type Drag = + | { kind: "user"; id: number; from: string | null } + | { kind: "account"; tool: PoolTool; name: string; from: string | null }; + +const fromPool = (pool: AccountPool): Draft[] => + (pool.groups ?? []).map((g) => ({ + key: `g${g.id}`, + id: g.id, + name: g.name, + userIds: g.users.map((u) => u.userId), + accounts: Object.fromEntries(TOOLS.map((x) => [x, [...(g.accounts[x] ?? [])]])) as Record< + PoolTool, + string[] + >, + })); +/** Order-free fingerprint of a draft, to tell what changed. */ +const sig = (d: Draft) => + JSON.stringify([ + d.name, + [...d.userIds].sort((a, b) => a - b), + TOOLS.map((x) => [...d.accounts[x]].sort()), + ]); +const sigAll = (ds: Draft[]) => ds.map((d) => `${d.key}:${sig(d)}`).join("|"); +const accountCount = (d: Pick) => + TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0); +const emptyAccounts = (): Record => ({ claude: [], codex: [], cursor: [] }); + export function PoolGroups({ pool, isAdmin, onChanged, + renderAccount, }: { pool: AccountPool; isAdmin: boolean; onChanged: () => void; + /** One pool account as a card (plan, quota, live panes, its actions). */ + renderAccount: (tool: PoolTool, name: string) => ReactNode; }) { const { t } = useTranslation("users"); - const groups = pool.groups ?? []; - const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(() => fromPool(pool)); + const [removed, setRemoved] = useState([]); + const base = useRef(pool); + // Set by Save: the next pool read replaces the board whatever it holds. + const saved = useRef(false); + const [users, setUsers] = useState(null); + const [profiles, setProfiles] = useState>({ + claude: [], + codex: [], + cursor: [], + }); + const [dragging, setDragging] = useState(null); + const [over, setOver] = useState(null); + const [picking, setPicking] = useState(null); + const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(null); + const seq = useRef(0); + + const dirty = removed.length > 0 || sigAll(draft) !== sigAll(fromPool(pool)); + // A re-read pool replaces an untouched board; a touched one keeps its edits + // but drops a pool that is gone. + useEffect(() => { + if (base.current === pool) return; + const untouched = + saved.current || (removed.length === 0 && sigAll(draft) === sigAll(fromPool(base.current))); + base.current = pool; + saved.current = false; + if (untouched) { + setDraft(fromPool(pool)); + setRemoved([]); + } else { + const ids = new Set((pool.groups ?? []).map((g) => g.id)); + setDraft((ds) => ds.filter((d) => d.id === null || ids.has(d.id))); + setRemoved((rs) => rs.filter((r) => ids.has(r.id))); + } + }, [pool]); + + useEffect(() => { + api.users + .list() + .then((r) => setUsers(r.users)) + .catch(() => setUsers([])); + }, []); + useEffect(() => { + if (!isAdmin) return; + for (const tool of PROFILE_TOOLS) { + api.accountPool + .profiles(tool) + .then((r) => setProfiles((p) => ({ ...p, [tool]: r.profiles }))) + .catch(() => {}); + } + }, [isAdmin, pool]); + + // Everyone the board knows: the user list, or at least each pool's members. + const people = useMemo(() => { + if (users && users.length) return users; + const seen = new Map(); + for (const g of pool.groups ?? []) + for (const u of g.users) + seen.set(u.userId, { + id: u.userId, + username: u.username, + role: "developer", + } as ServerUserUsage); + return [...seen.values()]; + }, [users, pool.groups]); + const byId = new Map(people.map((u) => [u.id, u])); + const nameOf = (d: Draft) => d.name.trim() || t("pool.board.untitled"); + const groupNameOf = (userId: number) => { + const d = draft.find((x) => x.userIds.includes(userId)); + return d ? nameOf(d) : null; + }; + const profileLabel = (tool: PoolTool, name: string) => + profiles[tool].find((p) => p.name === name)?.label ?? name; + /** The pool a dragged account would empty — the one a drop merges. */ + const mergeSource = (drag: Drag | null): Draft | null => { + if (!drag || drag.kind !== "account" || !drag.from) return null; + const src = draft.find((d) => d.key === drag.from); + return src && accountCount(src) === 1 ? src : null; + }; + + // ── Edits (staged) ──────────────────────────────────────────────────── + const patch = (key: string, fn: (d: Draft) => Draft) => + setDraft((ds) => ds.map((d) => (d.key === key ? fn(d) : d))); + /** A member is in one pool: take them out of every other. `to` null = shared. */ + const moveUsers = (ids: number[], to: string | null) => + setDraft((ds) => + ds.map((d) => { + const kept = d.userIds.filter((id) => !ids.includes(id)); + return { ...d, userIds: d.key === to ? [...kept, ...ids] : kept }; + }) + ); + /** An account is in one pool too: a move takes it out of every other. */ + const moveAccount = (tool: PoolTool, name: string, to: string | null) => + setDraft((ds) => + ds.map((d) => { + const kept = d.accounts[tool].filter((n) => n !== name); + return { + ...d, + accounts: { ...d.accounts, [tool]: d.key === to ? [...kept, name] : kept }, + }; + }) + ); + const nextKey = () => { + seq.current += 1; + return `new${seq.current}`; + }; + const newPool = (seed?: Partial>) => { + const key = nextKey(); + setDraft((ds) => [ + ...ds, + { key, id: null, name: "", userIds: [], accounts: emptyAccounts(), ...seed }, + ]); + return key; + }; + /** Split: what was dropped on New pool leaves its column for a pool of its own. */ + const split = (drag: Drag) => { + if (drag.kind === "user") { + moveUsers([drag.id], null); + newPool({ userIds: [drag.id] }); + } else { + moveAccount(drag.tool, drag.name, null); + newPool({ accounts: { ...emptyAccounts(), [drag.tool]: [drag.name] } }); + } + }; + /** Merge: `src`'s only account and every member join `to`; `src` goes away. */ + const merge = (src: Draft, tool: PoolTool, name: string, to: string | null) => { + const target = to ? draft.find((d) => d.key === to) : null; + setDraft((ds) => + ds + .filter((d) => d.key !== src.key) + .map((d) => + d.key === to + ? { + ...d, + userIds: [...d.userIds, ...src.userIds.filter((id) => !d.userIds.includes(id))], + accounts: { ...d.accounts, [tool]: [...d.accounts[tool], name] }, + } + : d + ) + ); + const into = target ? nameOf(target) : t("pool.groups.shared"); + if (src.id !== null) { + const id = src.id; + setRemoved((rs) => [...rs, { id, name: nameOf(src), into }]); + } + toast.info(t("pool.board.merged", { from: nameOf(src), into })); + }; + const onDrop = (e: DragEvent, to: string | null) => { + e.preventDefault(); + setOver(null); + setDragging(null); + let drag: Drag; + try { + drag = JSON.parse(e.dataTransfer.getData(DND)) as Drag; + } catch { + return; + } + if (to === NEW) return split(drag); + if (drag.from === to) return; + if (drag.kind === "user") return moveUsers([drag.id], to); + const src = mergeSource(drag); + if (src) return merge(src, drag.tool, drag.name, to); + moveAccount(drag.tool, drag.name, to); + }; + const dragProps = (drag: Drag) => + isAdmin + ? { + draggable: true, + onDragStart: (e: DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(DND, JSON.stringify(drag)); + e.dataTransfer.effectAllowed = "move"; + // After the browser took its drag image, so the image is not faded. + requestAnimationFrame(() => setDragging(drag)); + }, + onDragEnd: () => { + setDragging(null); + setOver(null); + }, + } + : {}; + const isDragged = (drag: Drag) => + dragging !== null && + dragging.kind === drag.kind && + (drag.kind === "user" + ? dragging.kind === "user" && dragging.id === drag.id + : dragging.kind === "account" && dragging.tool === drag.tool && dragging.name === drag.name); + const dropZone = (key: string | null) => + isAdmin + ? { + onDragOver: (e: DragEvent) => { + if (!e.dataTransfer.types.includes(DND)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setOver(key ?? SHARED); + }, + onDragLeave: (e: DragEvent) => { + // Leaving for a child of the same column is still over it. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setOver((o) => (o === (key ?? SHARED) ? null : o)); + }, + onDrop: (e: DragEvent) => onDrop(e, key), + } + : {}; + + // ── What Save will do ───────────────────────────────────────────────── + const original = new Map(fromPool(pool).map((d) => [d.key, sig(d)])); + const changedGroups = draft.filter((d) => d.id === null || original.get(d.key) !== sig(d)); + const pendingProfiles = [ + ...new Set( + draft.flatMap((d) => + TOOLS.flatMap((tool) => + d.accounts[tool].filter((n) => n.startsWith(PROFILE)).map((n) => `${tool}|${n}`) + ) + ) + ), + ]; + const unnamed = draft.some((d) => !d.name.trim()); + + const sharedAccounts = Object.fromEntries( + TOOLS.map((tool) => [ + tool, + accountNames(pool, tool).filter((n) => !draft.some((d) => d.accounts[tool].includes(n))), + ]) + ) as Record; + const ungrouped = people.filter((u) => groupNameOf(u.id) === null).map((u) => u.id); + const discard = () => { + setDraft(fromPool(pool)); + setRemoved([]); + }; + + const memberChip = (id: number, from: string | null) => { + const u = byId.get(id); + const name = u?.username ?? `#${id}`; + const drag: Drag = { kind: "user", id, from }; + return ( + + + {name.charAt(0)} + + {name} + {u && {t(`roles.${u.role}`)}} + {isAdmin && from && ( + + )} + + ); + }; + const accountItem = (tool: PoolTool, name: string, from: string | null) => { + const profile = name.startsWith(PROFILE); + const label = profile ? profileLabel(tool, name.slice(PROFILE.length)) : name; + const drag: Drag = { kind: "account", tool, name, from }; + return ( +
+ {profile ? ( +
+ + {label} + {isAdmin && from && ( + + )} +
+ ) : ( + renderAccount(tool, name) + )} +
+ ); + }; + /** A column's accounts, under a heading per tool that has any. */ + const accountList = (accounts: Record, from: string | null) => { + const tools = TOOLS.filter((tool) => accounts[tool].length > 0); + if (tools.length === 0) + return ( +

+ {isAdmin ? t("pool.board.dropAccounts") : t("pool.board.noAccounts")} +

+ ); + return tools.map((tool) => ( +
+
+ + {toolLabel(tool)} + + {accounts[tool].length} + +
+ {accounts[tool].map((n) => accountItem(tool, n, from))} +
+ )); + }; + /** Move-here menu: accounts in other pools, and saved profiles (pools only). */ + const moveMenu = (key: string | null, here: Record, name: string) => { + const options = TOOLS.map((tool) => ({ + tool, + accounts: accountNames(pool, tool).filter((n) => !here[tool].includes(n)), + profiles: (key && PROFILE_TOOLS.includes(tool) ? profiles[tool] : []).filter( + (p) => !draft.some((d) => d.accounts[tool].includes(PROFILE + p.name)) + ), + })); + if (!options.some((o) => o.accounts.length || o.profiles.length)) return null; + return ( + + ); + }; + const counts = (accounts: Record, members: number[]) => ( +
+ {t("pool.board.accountsCount", { count: accountCount({ accounts }) })} + · + {t("pool.groups.members", { count: members.length })} +
+ ); + const src = mergeSource(dragging); + const column = ( + key: string | null, + head: ReactNode, + accounts: Record, + members: number[], + testId: string, + name: string + ) => { + const zone = key ?? SHARED; + const isOver = over === zone && dragging?.from !== key; + const merging = src && src.key !== key; + return ( +
+ {head} + {isOver && merging && ( +
+ + {t("pool.board.mergeHint", { from: nameOf(src), into: name })} +
+ )} +
{accountList(accounts, key)}
+ {isAdmin && moveMenu(key, accounts, name)} +
+
+ {t("pool.members.title")} +
+
+ {members.length === 0 ? ( + + {isAdmin ? t("pool.board.dropMembers") : t("pool.board.noMembers")} + + ) : ( + members.map((id) => memberChip(id, key)) + )} +
+
+
+ ); + }; return ( -
-
- -
-

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

-

{t("pool.groups.description")}

-
+
+

+ {t("pool.groups.description")} {isAdmin && t("pool.board.howTo")} +

+ +
+ {column( + null, +
+
{t("pool.groups.shared")}
+
{t("pool.board.sharedHint")}
+ {counts(sharedAccounts, ungrouped)} +
, + sharedAccounts, + ungrouped, + "pool-shared", + t("pool.groups.shared") + )} + {draft.map((d) => { + const existing = d.id !== null ? (pool.groups ?? []).find((g) => g.id === d.id) : null; + const name = nameOf(d); + return ( +
+ {column( + d.key, +
+
+ {isAdmin ? ( + patch(d.key, (x) => ({ ...x, name: e.target.value }))} + placeholder={t("pool.groups.name")} + aria-label={t("pool.groups.name")} + /> + ) : ( +
{d.name}
+ )} +
+ {counts(d.accounts, d.userIds)} + {d.id === null && ( + + {t("pool.board.new")} + + )} +
+
+ {isAdmin && ( + <> + + + + )} +
, + d.accounts, + d.userIds, + "pool-group", + name + )} +
+ ); + })} {isAdmin && ( - )}
- {groups.length === 0 ? ( -

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

- ) : ( -
- {groups.map((g) => ( -
-
-
-
{g.name}
-
- {t("pool.groups.members", { count: g.users.length })} -
-
- {isAdmin && ( - <> - - - - )} -
- {g.users.length > 0 && ( -
- {g.users.map((u) => ( - - {u.username} - - ))} -
- )} -
- {POOL_TOOL_LABELS.map(({ id, label }) => { - const names = g.accounts[id] ?? []; - return ( -
- - {label}: - - {names.length ? names.join(", ") : t("pool.groups.noAccounts")} - -
- ); - })} -
-
- ))} + + {isAdmin && dirty && ( +
+ + {t("pool.board.pending", { + count: changedGroups.length + pendingProfiles.length + removed.length, + })} + {unnamed && ( + {t("pool.board.needsName")} + )} + + +
)} - {editing && ( - setEditing(null)} - onDone={(name) => { - toast.success(t("pool.groups.saved", { name })); - setEditing(null); + + {picking && ( + d.key === picking)!} + groupOf={groupNameOf} + onClose={() => setPicking(null)} + onDone={(ids) => { + const d = draft.find((x) => x.key === picking)!; + const added = ids.filter((id) => !d.userIds.includes(id)); + setDraft((ds) => + ds.map((x) => + x.key === picking + ? { ...x, userIds: ids } + : { ...x, userIds: x.userIds.filter((id) => !added.includes(id)) } + ) + ); + setPicking(null); + }} + /> + )} + {saving && ( + setSaving(false)} + onProgress={(d, r) => { + setDraft(d); + setRemoved(r); + }} + onDone={() => { + toast.success(t("pool.board.saved")); + setSaving(false); + saved.current = true; onChanged(); }} /> @@ -175,71 +755,139 @@ export function PoolGroups({ ); } -/** Create a group, or edit one: its name, its members and its accounts. */ -function GroupDialog({ - pool, +/** A column's Pick members: tick by role or "No group"; staged on the board. */ +function PickMembersDialog({ + users, group, + groupOf, onClose, onDone, }: { - pool: AccountPool; - group: PoolGroup | null; + users: ServerUserUsage[] | null; + group: Draft; + groupOf: (userId: number) => string | null; onClose: () => void; - onDone: (name: string) => void; + onDone: (ids: number[]) => void; }) { const { t } = useTranslation("users"); - const [name, setName] = useState(group?.name ?? ""); - const [users, setUsers] = useState(null); - const [members, setMembers] = useState>( - () => new Set(group?.users.map((u) => u.userId) ?? []) + const [chosen, setChosen] = useState>(() => new Set(group.userIds)); + const name = group.name || t("pool.board.untitled"); + return ( + onDone([...chosen])} + onCancel={onClose} + > +
+ { + const g = groupOf(u.id); + return g && g !== group.name ? ( + + {t("pool.groups.inGroup", { group: g })} + + ) : null; + }} + /> +
+
); - const [accounts, setAccounts] = useState>>(() => ({ - claude: new Set(group?.accounts.claude ?? []), - codex: new Set(group?.accounts.codex ?? []), - cursor: new Set(group?.accounts.cursor ?? []), - })); +} + +/** + * Save: the admin's password once, then every staged change in order — + * profiles moved into the pool, new pools created, changed pools set, merged + * pools deleted. What already went through is written back into the board, so + * a retry after a failure never moves a profile in twice, makes a pool again + * or deletes one twice. + */ +function SaveBoardDialog({ + draft, + changed, + profiles, + removed, + profileLabel, + onClose, + onProgress, + onDone, +}: { + draft: Draft[]; + changed: Draft[]; + /** `tool|profile:name` entries to move into the pool first. */ + profiles: string[]; + /** Saved pools merged into another, deleted last. */ + removed: Removed[]; + profileLabel: (tool: PoolTool, name: string) => string; + onClose: () => void; + onProgress: (d: Draft[], removed: Removed[]) => void; + onDone: () => void; +}) { + const { t } = useTranslation("users"); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - api.users - .list() - .then((r) => setUsers(r.users)) - .catch(() => setUsers([])); - }, []); - - const groupOfUser = (userId: number) => - (pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId)); - const toggle = (set: Set, v: T, on: boolean) => { - const next = new Set(set); - if (on) next.add(v); - else next.delete(v); - return next; - }; - const ready = name.trim().length > 0 && current.length > 0; - const submit = async () => { - if (!ready || busy) return; + if (!current || busy) return; setBusy(true); setError(null); + let work = draft.map((d) => ({ + ...d, + userIds: [...d.userIds], + accounts: { ...d.accounts }, + })); + const todo = new Set(changed.map((d) => d.key)); + let gone = [...removed]; try { - const id = group - ? group.id - : (await api.accountPool.createGroup(name.trim(), current)).group.id; - await api.accountPool.updateGroup( - id, - { - ...(group ? { name: name.trim() } : {}), - userIds: [...members], - accounts: Object.fromEntries( - POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]]) - ), - }, - current - ); - onDone(name.trim()); + for (const entry of profiles) { + const [tool, ref] = entry.split("|") as [PoolTool, string]; + const profile = ref.slice(PROFILE.length); + const r = await api.accountPool.add({ tool, name: profile, profile }, current); + work = work.map((d) => ({ + ...d, + accounts: { + ...d.accounts, + [tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)), + }, + })); + } + for (const d of work) { + if (!todo.has(d.key)) continue; + let id = d.id; + if (id === null) { + id = (await api.accountPool.createGroup(d.name.trim(), current)).group.id; + const made = id; + work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x)); + } + await api.accountPool.updateGroup( + id, + { + ...(d.id !== null ? { name: d.name.trim() } : {}), + userIds: d.userIds, + accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])), + }, + current + ); + todo.delete(d.key); + } + // Last: the merged pools' members already moved into their new pool. + for (const r of removed) { + await api.accountPool.deleteGroup(r.id, current); + gone = gone.filter((x) => x.id !== r.id); + } + onDone(); } catch (e) { + onProgress(work, gone); setError(errorText(t, e)); } finally { setBusy(false); @@ -249,123 +897,63 @@ function GroupDialog({ return ( void submit()} onCancel={onClose} > -
{ - if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) { - e.preventDefault(); - void submit(); - } - }} - > - -
- - {t("pool.groups.memberList")} - -
- {users === null && ( -

{t("pool.loadingUsers")}

- )} - {users?.length === 0 && ( -

{t("pool.noUsers")}

- )} - {users?.map((u) => { - const other = groupOfUser(u.id); - return ( -
- setMembers((s) => toggle(s, u.id, on))} - label={ - - {u.username} - {other && ( - - {t("pool.groups.inGroup", { group: other.name })} - - )} - - } - labelClassName="text-sm text-fg-primary" - /> -
- ); +
    + {profiles.map((entry) => { + const [tool, ref] = entry.split("|") as [PoolTool, string]; + return ( +
  • + {t("pool.board.summaryProfile", { + name: profileLabel(tool, ref.slice(PROFILE.length)), + })} +
  • + ); + })} + {removed.map((r) => ( +
  • {t("pool.board.summaryMerge", { from: r.name, into: r.into })}
  • + ))} + {changed.map((d) => ( +
  • + {t(d.id === null ? "pool.board.summaryNew" : "pool.board.summaryGroup", { + name: d.name.trim(), + count: d.userIds.length, + accounts: TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0), })} -
-
-
- - {t("pool.groups.accountList")} - -
- {POOL_TOOL_LABELS.map(({ id: tool, label }) => { - const names = accountNames(pool, tool); - return ( -
-
- - {label} -
- {names.length === 0 ? ( -

{t("pool.groups.noneInPool")}

- ) : ( -
- {names.map((n) => ( - - setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) })) - } - label={n} - labelClassName="text-sm text-fg-primary" - /> - ))} -
- )} -
- ); - })} -
-
- - {error && ( -

- {error} -

- )} -
+ + ))} + + + {error && ( +

+ {error} +

+ )}
); } diff --git a/client/src/components/users/__tests__/AccountPoolCard.test.tsx b/client/src/components/users/__tests__/AccountPoolCard.test.tsx index 0bf956be..f658f106 100644 --- a/client/src/components/users/__tests__/AccountPoolCard.test.tsx +++ b/client/src/components/users/__tests__/AccountPoolCard.test.tsx @@ -1,19 +1,24 @@ /** * @file AccountPoolCard.test.tsx - * @description The Users page's account pool card: one tab per AI tool — the - * Claude, Codex and Cursor pools with their accounts' live panes and quota, - * Oh My Pi's login sharing, and a "no pool" note for any other installed - * tool; says what an empty pool means per tool, an + * @description The Users page's account pool card: the pools board with every + * tool's accounts (live panes and quota) and members per pool, then a tab per + * tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any + * other installed tool; an * admin adds an account only with their own password typed (the token goes * in once and is not shown back) — a saved login moved in (the server's own * included), or a setup-token — and removes one the same way, and an analyst * sees no add or remove control. An account's Edit ticks the users who run on * it, saved with the admin's own password; the users show on the account. + * The board moves members and accounts between pools (drag, or a column's + * menu) — an account sits in one pool, so a move takes it out of the other — + * splits an account off onto New pool, merges a pool by dragging its only + * account into another, adds a saved profile to a pool, shows each account's + * plan, and saves every change with ONE password. * @author Nguyễn Ngọc Trí Vĩ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import type { AccountPool } from "../../../lib/api"; @@ -129,6 +134,28 @@ const OMP = { gateway: { running: false }, }; +/** The account names a pool column holds (its move menu names others too). */ +const held = (col: HTMLElement) => + within(col) + .queryAllByTestId("pool-group-account") + .map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent); + +/** A drag-and-drop between two elements, through one shared DataTransfer. */ +function drag(from: Element, to: Element) { + const store: Record = {}; + const dataTransfer = { + setData: (k: string, v: string) => (store[k] = v), + getData: (k: string) => store[k] ?? "", + get types() { + return Object.keys(store); + }, + effectAllowed: "", + }; + fireEvent.dragStart(from, { dataTransfer }); + fireEvent.dragOver(to, { dataTransfer }); + fireEvent.drop(to, { dataTransfer }); +} + beforeEach(() => { for (const fn of Object.values(accountPool)) fn.mockReset(); aiTools.list.mockResolvedValue({ @@ -147,8 +174,8 @@ beforeEach(() => { accountPool.list.mockResolvedValue(POOL); users.list.mockResolvedValue({ users: [ - { id: 7, username: "mia", disabled: false }, - { id: 8, username: "leo", disabled: false }, + { id: 7, username: "mia", role: "developer", disabled: false }, + { id: 8, username: "leo", role: "analyst", disabled: false }, ], }); accountPool.profiles.mockResolvedValue({ @@ -166,11 +193,15 @@ beforeEach(() => { }); describe("AccountPoolCard", () => { - it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => { + it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => { const user = userEvent.setup(); - const { unmount } = render(card(false)); + render(card(false)); expect(await screen.findByText("team-1")).toBeInTheDocument(); - expect(screen.queryByText("cx-1")).toBeNull(); + // With no pool made yet, every account sits in the shared pool, by tool. + const shared = screen.getByTestId("pool-shared"); + expect(shared).toHaveTextContent("Claude Code2"); + expect(shared).toHaveTextContent("Codex1"); + expect(shared).toHaveTextContent("cx-1"); const [account, second] = screen.getAllByTestId("pool-account"); // Every account of the pool has its card; quota is shown as what is LEFT. expect(second).toHaveTextContent("team-2"); @@ -188,39 +219,25 @@ describe("AccountPoolCard", () => { expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days"); expect(account).toHaveTextContent("2 live"); expect(screen.getByText("team1@example.com")).toBeInTheDocument(); - // Without groups nothing is said about them on an account. - expect(screen.queryByText("Shared pool")).toBeNull(); - expect(screen.queryByRole("button", { name: /^add/i })).toBeNull(); + // An analyst reads: no add, remove or new pool. + expect(screen.queryByTestId("pool-add-account")).toBeNull(); expect(screen.queryByRole("button", { name: /remove/i })).toBeNull(); - const codexTab = screen.getByRole("tab", { name: /Codex/ }); - expect(codexTab).toHaveTextContent("1"); - expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2"); - await user.click(codexTab); - expect(screen.getByText("cx-1")).toBeInTheDocument(); - expect(screen.queryByText("team-1")).toBeNull(); - // A pool with no account still has its tab, with what that means. - await user.click(screen.getByRole("tab", { name: /Cursor CLI/ })); - expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument(); - // An installed tool without a pool says so; an uninstalled one gets no tab. + expect(screen.queryByRole("button", { name: "New pool" })).toBeNull(); + // Pool tools have no tab any more; an installed tool without a pool does. + expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull(); await user.click(screen.getByRole("tab", { name: /Antigravity/ })); expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument(); expect(screen.getByText(/system keyring/)).toBeInTheDocument(); expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull(); - unmount(); - accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 }); - render(card(true)); - expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument(); }); - it("adds a Cursor API key from the Cursor tab", async () => { + it("adds a Cursor API key from Add account", async () => { accountPool.add.mockResolvedValue({ account: { name: "cu-1" } }); accountPool.list.mockResolvedValue({ ...POOL, cursor: [] }); const user = userEvent.setup(); render(card(true)); await screen.findByText("team-1"); - await user.click(screen.getByRole("tab", { name: /Cursor CLI/ })); - await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" })); + await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor"); const dialog = await screen.findByRole("dialog"); expect(screen.queryByLabelText("From")).toBeNull(); await user.type(screen.getByLabelText("Name"), "cu-1"); @@ -238,27 +255,7 @@ describe("AccountPoolCard", () => { expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor"); }); - it("shares a saved profile from its card", async () => { - accountPool.add.mockResolvedValue({ account: { name: "work" } }); - const user = userEvent.setup(); - render(card(true)); - await screen.findByText("team-1"); - await user.click(await screen.findByRole("button", { name: "Share Work to the pool" })); - const dialog = await screen.findByRole("dialog"); - await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work")); - await user.type(screen.getByLabelText("Your password"), "boss-pass"); - await user.click( - Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")! - ); - await waitFor(() => - expect(accountPool.add).toHaveBeenCalledWith( - { tool: "claude", name: "work", profile: "work" }, - "boss-pass" - ) - ); - }); - - it("shows the groups as cards and saves a new one with members and accounts", async () => { + it("builds pools on the board: members, accounts and profiles moved in, one password", async () => { accountPool.list.mockResolvedValue({ ...POOL, groups: [ @@ -272,28 +269,180 @@ describe("AccountPoolCard", () => { }); accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } }); accountPool.updateGroup.mockResolvedValue({ group: {} }); + accountPool.add.mockResolvedValue({ account: { name: "work" } }); const user = userEvent.setup(); render(card(true)); - const groupCard = await screen.findByTestId("pool-group"); - expect(groupCard).toHaveTextContent("Team A"); - expect(groupCard).toHaveTextContent("leo"); - expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A"); - await user.click(screen.getByRole("button", { name: "New group" })); - await screen.findByRole("dialog"); - await user.type(screen.getByLabelText("Group name"), "Team B"); - await user.click(await screen.findByRole("checkbox", { name: /leo/ })); - expect(screen.getByText("now in Team A")).toBeInTheDocument(); - await user.click(screen.getByRole("checkbox", { name: "cx-1" })); - await user.type(screen.getByLabelText("Your password"), "boss-pass"); - await user.click(screen.getByRole("button", { name: "Save" })); + const teamA = await screen.findByTestId("pool-group"); + expect(teamA).toHaveTextContent("leo"); + expect(teamA).toHaveTextContent("team-1"); + await screen.findByText("mia"); + + await user.click(screen.getByRole("button", { name: "New pool" })); + const names = screen.getAllByLabelText("Pool name"); + await user.type(names[names.length - 1]!, "Team B"); + const teamB = screen.getAllByTestId("pool-group")[1]!; + drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB); + expect(teamB).toHaveTextContent("mia"); + const add = within(teamB).getByLabelText("Move an account into Team B"); + await user.selectOptions(add, "codex|cx-1"); + await user.selectOptions(add, "claude|profile:work"); + expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1"); + expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes"); + expect(accountPool.updateGroup).not.toHaveBeenCalled(); + + await user.click( + within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" }) + ); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent("Move the saved profile Work into the pool"); + await user.type(within(dialog).getByLabelText("Your password"), "boss-pass"); + await user.click(within(dialog).getByRole("button", { name: "Save" })); await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledWith( 2, - { userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } }, + { userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } }, "boss-pass" ) ); + expect(accountPool.add).toHaveBeenCalledWith( + { tool: "claude", name: "work", profile: "work" }, + "boss-pass" + ); expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass"); + // Team A did not change: nothing is sent for it. + expect(accountPool.updateGroup).toHaveBeenCalledTimes(1); + }); + + it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => { + accountPool.list.mockResolvedValue({ + ...POOL, + groups: [ + { + id: 1, + name: "Team A", + users: [{ userId: 8, username: "leo" }], + accounts: { claude: ["team-1"], codex: [], cursor: [] }, + }, + ], + }); + accountPool.updateGroup.mockResolvedValue({ group: {} }); + const user = userEvent.setup(); + render(card(true)); + const teamA = await screen.findByTestId("pool-group"); + const shared = screen.getByTestId("pool-shared"); + const item = (col: HTMLElement, name: string) => + within(col) + .getAllByTestId("pool-group-account") + .find((c) => c.textContent?.includes(name))!; + drag(item(shared, "team-2"), teamA); + expect(held(teamA)).toEqual(["team-1", "team-2"]); + // And back again: an account is in one pool, so it leaves the other. + drag(item(teamA, "team-1"), shared); + expect(held(teamA)).toEqual(["team-2"]); + expect(held(shared)).toContain("team-1"); + expect(teamA).toHaveTextContent("team-2"); + await user.click( + within(teamA).getByRole("button", { name: "Send leo back to the shared pool" }) + ); + expect(shared).toHaveTextContent("leo"); + await user.click( + within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" }) + ); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Your password"), "boss-pass"); + await user.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => + expect(accountPool.updateGroup).toHaveBeenCalledWith( + 1, + { + name: "Team A", + userIds: [], + accounts: { claude: ["team-2"], codex: [], cursor: [] }, + }, + "boss-pass" + ) + ); + }); + + it("shows each account's plan on its card", async () => { + accountPool.list.mockResolvedValue({ + ...POOL, + accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!], + }); + render(card(false)); + const [first, second] = await screen.findAllByTestId("pool-account"); + expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x"); + expect(within(second!).queryByTestId("pool-account-plan")).toBeNull(); + }); + + it("splits an account off onto New pool, and merges a pool by its only account", async () => { + accountPool.list.mockResolvedValue({ + ...POOL, + groups: [ + { + id: 1, + name: "Team A", + users: [{ userId: 8, username: "leo" }], + accounts: { claude: ["team-1"], codex: [], cursor: [] }, + }, + { + id: 2, + name: "Team B", + users: [{ userId: 7, username: "mia" }], + accounts: { claude: [], codex: ["cx-1"], cursor: [] }, + }, + ], + }); + accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } }); + accountPool.updateGroup.mockResolvedValue({ group: {} }); + accountPool.deleteGroup.mockResolvedValue({ ok: true }); + const user = userEvent.setup(); + render(card(true)); + const [teamA, teamB] = await screen.findAllByTestId("pool-group"); + await screen.findByText("mia"); + const item = (col: HTMLElement, name: string) => + within(col) + .getAllByTestId("pool-group-account") + .find((c) => c.textContent?.includes(name))!; + // Split: team-2 leaves the shared pool for a new pool of its own. + drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new")); + const pools = screen.getAllByTestId("pool-group"); + expect(pools).toHaveLength(3); + expect(held(pools[2]!)).toEqual(["team-2"]); + await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo"); + // Merge: Team B's only account into Team A brings mia along; Team B goes. + drag(item(teamB!, "cx-1"), teamA!); + expect(screen.getAllByTestId("pool-group")).toHaveLength(2); + expect(held(teamA!)).toEqual(["team-1", "cx-1"]); + expect(teamA).toHaveTextContent("mia"); + + await user.click( + within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" }) + ); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted"); + await user.type(within(dialog).getByLabelText("Your password"), "boss-pass"); + await user.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass")); + expect(accountPool.updateGroup).toHaveBeenCalledWith( + 1, + { + name: "Team A", + userIds: [8, 7], + accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] }, + }, + "boss-pass" + ); + expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass"); + expect(accountPool.updateGroup).toHaveBeenCalledWith( + 3, + { userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } }, + "boss-pass" + ); + // The members moved before the emptied pool was deleted. + const order = (fn: { mock: { invocationCallOrder: number[] } }) => + fn.mock.invocationCallOrder[0]!; + expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup)); }); it("turns Oh My Pi login sharing on from its tab", async () => { @@ -312,7 +461,7 @@ describe("AccountPoolCard", () => { const user = userEvent.setup(); render(card(true)); await screen.findByText("team-1"); - await user.click(screen.getByRole("button", { name: "Add Claude Code account" })); + await user.selectOptions(screen.getByTestId("pool-add-account"), "claude"); const dialog = await screen.findByRole("dialog"); const select = await screen.findByLabelText("Profile"); await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument()); @@ -337,7 +486,7 @@ describe("AccountPoolCard", () => { const user = userEvent.setup(); render(card(true)); await screen.findByText("team-1"); - await user.click(screen.getByRole("button", { name: "Add Claude Code account" })); + await user.selectOptions(screen.getByTestId("pool-add-account"), "claude"); const dialog = await screen.findByRole("dialog"); await user.selectOptions(screen.getByLabelText("From"), "token"); await user.type(screen.getByLabelText("Name"), "team-2"); @@ -362,7 +511,6 @@ describe("AccountPoolCard", () => { const user = userEvent.setup(); render(card(true)); await screen.findByText("team-1"); - await user.click(screen.getByRole("tab", { name: /Codex/ })); await user.click(await screen.findByRole("button", { name: "Remove cx-1" })); await user.type(await screen.findByLabelText("Your password"), "boss-pass"); await user.click(screen.getByRole("button", { name: "Remove" })); @@ -425,7 +573,6 @@ describe("AccountPoolCard", () => { render(card(true, "/admin?poolAdd=codex&poolProfile=cx")); const dialog = await screen.findByRole("dialog"); await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex")); - expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true"); await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx")); await user.type(screen.getByLabelText("Your password"), "boss-pass"); const confirm = Array.from(dialog.querySelectorAll("button")).find( diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index cadbe926..e7459d97 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -133,7 +133,7 @@ }, "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", "add": "Add account", "account": "Account", @@ -254,20 +254,20 @@ "shareNamed": "Share {{name}} to the pool" }, "groups": { - "title": "Groups", - "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.", - "new": "New group", + "title": "Pools", + "description": "Members in no pool, and accounts in no pool, make up the shared pool.", + "new": "New pool", "empty": "No groups yet — every user shares every account.", "members_one": "{{count}} member", "members_other": "{{count}} members", "edit": "Edit group {{name}}", - "delete": "Delete group {{name}}", - "deleteHint": "Its members go back to the shared pool. No account is removed.", + "delete": "Delete pool {{name}}", + "deleteHint": "Its members and accounts go back to the shared pool. No account is removed.", "noAccounts": "none — members sign in themselves", "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.", - "name": "Group name", + "name": "Pool name", "memberList": "Members", "inGroup": "now in {{group}}", "accountList": "Accounts", @@ -278,7 +278,57 @@ "5h": "5 hours", "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": { "button": "Delete user", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index c601c313..141fb8f3 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -133,7 +133,7 @@ }, "pool": { "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", "add": "Thêm tài khoản", "account": "Tài khoản", @@ -254,20 +254,20 @@ "shareNamed": "Chia sẻ {{name}} vào pool" }, "groups": { - "title": "Nhóm", - "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.", - "new": "Tạo nhóm", + "title": "Pool", + "description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.", + "new": "Tạo pool", "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_other": "{{count}} thành viên", "edit": "Sửa nhóm {{name}}", - "delete": "Xoá nhóm {{name}}", - "deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.", + "delete": "Xoá pool {{name}}", + "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", "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.", - "name": "Tên nhóm", + "name": "Tên pool", "memberList": "Thành viên", "inGroup": "đang ở {{group}}", "accountList": "Tài khoản", @@ -278,7 +278,57 @@ "5h": "5 giờ", "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": { "button": "Xoá user", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f4a28769..76b6fe7d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -810,6 +810,8 @@ export type PoolAccount = { } | null; quotaPercent: number | null; 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. */ export type CodexPoolAccount = { @@ -828,6 +830,8 @@ export type CodexPoolAccount = { } | null; quotaPercent: number | null; 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. */ export type AccountPin = { @@ -4778,6 +4782,8 @@ export interface CcAccountIdentity { organization: string | null; seatTier: 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 * token is stored too). */ expiresAt: number | null; diff --git a/client/src/pages/__tests__/Users.test.tsx b/client/src/pages/__tests__/Users.test.tsx index a6b9b136..3ee36b62 100644 --- a/client/src/pages/__tests__/Users.test.tsx +++ b/client/src/pages/__tests__/Users.test.tsx @@ -228,6 +228,8 @@ describe("Users page", () => { expect(confirm.hasAttribute("disabled")).toBe(true); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); 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 waitFor(() => expect(users.create).toHaveBeenCalledWith( @@ -236,7 +238,7 @@ describe("Users page", () => { ) ); 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 () => { diff --git a/docs/API.md b/docs/API.md index 16ac659f..267878c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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). -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=&poolProfile=` 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=&poolProfile=` 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`): @@ -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] ``` -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=::` 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 `d`/`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=::` 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 `d`/`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 ```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 `/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`.json` and `/.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 `/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 `/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. diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 329fa142..7c0ab869 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -595,7 +595,7 @@ agenthub accounts pin cursor-team mia --tool cursor ## Account pool groups 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`): - 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 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 When the pool holds more than one account a user may use, they pick which one diff --git a/server/__tests__/cc-profiles.test.js b/server/__tests__/cc-profiles.test.js index fd3a8d2f..54f3bb5c 100644 --- a/server/__tests__/cc-profiles.test.js +++ b/server/__tests__/cc-profiles.test.js @@ -444,3 +444,21 @@ describe("cc-profiles — identity and token disagree", () => { 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); +}); diff --git a/server/__tests__/quota.test.js b/server/__tests__/quota.test.js index ccb49da8..4aeb2b50 100644 --- a/server/__tests__/quota.test.js +++ b/server/__tests__/quota.test.js @@ -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.account, "victor@x.y"); assert.strictEqual(live.ownerUuid, undefined); - assert.strictEqual(live.plan, "max"); + assert.strictEqual(live.plan, "Max"); assert.deepStrictEqual( claude.filter((t) => !t.active).map((t) => t.label), ["Thắng"] diff --git a/server/lib/cc-profiles.js b/server/lib/cc-profiles.js index 8482c3dc..77a275b1 100644 --- a/server/lib/cc-profiles.js +++ b/server/lib/cc-profiles.js @@ -179,6 +179,47 @@ function readActiveAuth() { 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. */ function identityOf({ oauth, account }) { return { @@ -188,6 +229,8 @@ function identityOf({ oauth, account }) { organization: (account && account.organizationName) || null, seatTier: (account && account.seatTier) || 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 * (the refresh token is stored too), it just refreshes on first use. */ expiresAt: (oauth && oauth.expiresAt) || null, @@ -544,6 +587,7 @@ module.exports = { writeProfile, liveCredentialsFor, readActiveAuth, + planLabel, // exported for tests profilesDir, configPaths, diff --git a/server/lib/claude-pool.js b/server/lib/claude-pool.js index cc4c9557..0b758bed 100644 --- a/server/lib/claude-pool.js +++ b/server/lib/claude-pool.js @@ -435,6 +435,8 @@ function poolStatus() { name: a.name, kind: a.kind, 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, addedAt: a.addedAt, expiresAt: expiresAt(a), diff --git a/server/lib/codex-pool.js b/server/lib/codex-pool.js index c1f566ab..f706478f 100644 --- a/server/lib/codex-pool.js +++ b/server/lib/codex-pool.js @@ -245,6 +245,7 @@ function poolStatus() { return { name: a.name, kind: a.kind, + plan: a.kind === "chatgpt" ? planOf(a.auth) : null, addedAt: a.addedAt, livePanes: livePanes(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 * placed `a` in that home itself (`placedIn`), its `last_refresh` is no later diff --git a/server/lib/quota.js b/server/lib/quota.js index f0293fe8..8bbb973c 100644 --- a/server/lib/quota.js +++ b/server/lib/quota.js @@ -36,7 +36,7 @@ * `AGENTHUB_AGY_USAGE=0` never runs it. * * 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 * `expiresAt`, when its stored access token runs out — Claude's * `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 aiTools = require("./ai-tools"); 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 { allRolloutFiles } = require("./codex-ingest"); 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 who = { account: account?.emailAddress || account?.displayName || null, - plan: oauth.subscriptionType || null, + plan: planLabel(oauth, account), expiresAt: iso(oauth.expiresAt), }; if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" }; @@ -172,7 +172,7 @@ async function liveClaude(readAuth = readActiveAuth) { async function profileClaude(item) { const who = { account: item.email || item.displayName || null, - plan: item.subscriptionType || null, + plan: item.plan || item.subscriptionType || null, expiresAt: iso(item.expiresAt), }; let creds = null; diff --git a/server/routes/accounts.js b/server/routes/accounts.js index 43b811df..45f6f0d4 100644 --- a/server/routes/accounts.js +++ b/server/routes/accounts.js @@ -88,7 +88,7 @@ router.get("/profiles", (req, res) => { name: p.name, label: p.label, email: p.email, - subscriptionType: p.subscriptionType, + subscriptionType: p.plan || p.subscriptionType, active: p.active, })), });