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

- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
This commit is contained in:
2026-10-22 17:02:08 +07:00
parent 44426faf16
commit 685eb748e8
30 changed files with 2891 additions and 299 deletions
@@ -1,7 +1,9 @@
/**
* @file AccountPoolCard.test.tsx
* @description The Users page's account pool card: lists the Claude and Codex
* accounts with their live panes and quota, says what an empty pool means, an
* @description The Users page's account pool card: one tab per AI tool — the
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
* Oh My Pi's login sharing, and a "no pool" note for any other installed
* tool; says what an empty pool means per tool, an
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
@@ -22,11 +24,20 @@ const accountPool = vi.hoisted(() => ({
remove: vi.fn(),
profiles: vi.fn(),
setUsers: vi.fn(),
createGroup: vi.fn(),
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
const aiTools = vi.hoisted(() => ({
list: vi.fn(),
ompSharing: vi.fn(),
setOmpSharing: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool, users } };
return { ...actual, api: { accountPool, users, aiTools } };
});
import { AccountPoolCard } from "../AccountPoolCard";
@@ -53,9 +64,44 @@ const POOL: AccountPool = {
spend7d: 12,
tokens5h: 250_000,
tokens7d: 3_400_000,
quota: {
fiveHour: {
utilization: 42,
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
},
sevenDay: {
utilization: 15,
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
},
checkedAt: "2026-10-01T00:00:00Z",
error: null,
},
quotaPercent: 42,
full: false,
},
{
name: "team-2",
kind: "token",
email: null,
refreshError: null,
addedAt: "2026-10-01T00:00:00Z",
expiresAt: null,
expired: false,
livePanes: 0,
spend5h: 0,
spend7d: 0,
tokens5h: 0,
tokens7d: 0,
// A 5-hour window that has reset since the reading is full again.
quota: {
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
sevenDay: null,
checkedAt: "2026-10-01T00:00:00Z",
error: null,
},
quotaPercent: null,
full: false,
},
],
codex: [
{
@@ -74,8 +120,30 @@ const POOL: AccountPool = {
blockPercent: 90,
};
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
const OMP = {
available: true,
enabled: false,
installed: true,
broker: { running: false },
gateway: { running: false },
};
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({
tools: [
tool("claude", "Claude Code"),
tool("codex", "Codex"),
tool("cursor", "Cursor CLI"),
tool("omp", "Oh My Pi"),
tool("antigravity", "Antigravity"),
tool("goose", "Goose", false),
],
default: "claude",
});
aiTools.ompSharing.mockResolvedValue(OMP);
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
@@ -98,20 +166,145 @@ beforeEach(() => {
});
describe("AccountPoolCard", () => {
it("lists every account, and explains an empty pool", async () => {
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
const user = userEvent.setup();
const { unmount } = render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
expect(screen.queryByText("cx-1")).toBeNull();
const [account, second] = screen.getAllByTestId("pool-account");
// Every account of the pool has its card; quota is shown as what is LEFT.
expect(second).toHaveTextContent("team-2");
const quota = screen.getAllByTestId("pool-quota")[0]!;
expect(quota).toHaveTextContent("5 hours");
expect(quota).toHaveTextContent("58%");
expect(quota).toHaveTextContent("resets in 2h 10m");
expect(quota).toHaveTextContent("Week");
expect(quota).toHaveTextContent("85%");
expect(quota).toHaveTextContent("resets in 3d 1h");
const reset = screen.getAllByTestId("pool-quota")[1]!;
expect(reset).toHaveTextContent("100%");
expect(reset).toHaveTextContent("—");
expect(account).toHaveTextContent("250.0K tokens");
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
expect(account).toHaveTextContent("2 live");
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
// Without groups nothing is said about them on an account.
expect(screen.queryByText("Shared pool")).toBeNull();
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
const codexTab = screen.getByRole("tab", { name: /Codex/ });
expect(codexTab).toHaveTextContent("1");
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
await user.click(codexTab);
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.queryByText("team-1")).toBeNull();
// A pool with no account still has its tab, with what that means.
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
// An installed tool without a pool says so; an uninstalled one gets no tab.
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
unmount();
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
render(card(true));
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
});
it("adds a Cursor API key from the Cursor tab", async () => {
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
const dialog = await screen.findByRole("dialog");
expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1");
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "cursor", name: "cu-1", token: "key_abc" },
"boss-pass"
)
);
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
});
it("shares a saved profile from its card", async () => {
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
)
);
});
it("shows the groups as cards and saves a new one with members and accounts", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
],
});
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
const groupCard = await screen.findByTestId("pool-group");
expect(groupCard).toHaveTextContent("Team A");
expect(groupCard).toHaveTextContent("leo");
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
await user.click(screen.getByRole("button", { name: "New group" }));
await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Group name"), "Team B");
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
expect(screen.getByText("now in Team A")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
2,
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
"boss-pass"
)
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
});
it("turns Oh My Pi login sharing on from its tab", async () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
});
it("moves a saved login in, the server's own included", async () => {
@@ -119,7 +312,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
const dialog = await screen.findByRole("dialog");
const select = await screen.findByLabelText("Profile");
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
@@ -144,7 +337,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
const dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2");
@@ -168,6 +361,8 @@ describe("AccountPoolCard", () => {
accountPool.remove.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Codex/ }));
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" }));
@@ -230,7 +425,7 @@ describe("AccountPoolCard", () => {
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find(