/** * @file PoolGroups.tsx * @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, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; 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 { AiToolIcon } from "../AiToolIcon"; import { MemberPicker } from "./MemberPicker"; /** 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[] { const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor; return (list ?? []).map((a) => a.name); } function errorText(t: TFunction<"users">, e: unknown): string { const err = e as Error & { code?: string }; return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" }) ? t(`pool.errors.${err.code}`) : err.message; } /** 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 [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.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 && ( )}
{isAdmin && dirty && (
{t("pool.board.pending", { count: changedGroups.length + pendingProfiles.length + removed.length, })} {unnamed && ( {t("pool.board.needsName")} )}
)} {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(); }} /> )} {deleting && ( setDeleting(null)} onDone={() => { toast.success(t("pool.groups.deleted", { name: deleting.name })); setDeleting(null); onChanged(); }} /> )}
); } /** A column's Pick members: tick by role or "No group"; staged on the board. */ function PickMembersDialog({ users, group, groupOf, onClose, onDone, }: { users: ServerUserUsage[] | null; group: Draft; groupOf: (userId: number) => string | null; onClose: () => void; onDone: (ids: number[]) => void; }) { const { t } = useTranslation("users"); 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; }} />
); } /** * 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); const submit = async () => { 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 { 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); } }; return ( void submit()} onCancel={onClose} >
    {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), })}
  • ))}
{error && (

{error}

)}
); } function DeleteGroupDialog({ group, onClose, onDone, }: { group: PoolGroup; onClose: () => void; onDone: () => void; }) { const { t } = useTranslation("users"); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const submit = async () => { if (!current || busy) return; setBusy(true); setError(null); try { await api.accountPool.deleteGroup(group.id, current); onDone(); } catch (e) { setError(errorText(t, e)); } finally { setBusy(false); } }; return ( void submit()} onCancel={onClose} > {error && (

{error}

)}
); }