Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 241e19b713 | |||
| 685eb748e8 |
+41
-8
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -4328,8 +4328,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||
async function cmdCursorAccounts(sub, name) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||
);
|
||||
const added = pool.addAccount(name, {
|
||||
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||
});
|
||||
console.log(`${c.green("✔")} ${added.name} added`);
|
||||
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||
}
|
||||
table(
|
||||
["cursor account", "live", "pinned"],
|
||||
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
@@ -4337,7 +4365,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
||||
const [first, second] = positional;
|
||||
const username = sub === "pin" ? second : first;
|
||||
if (!username || (sub === "pin" && !first)) {
|
||||
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
||||
throw new Error(
|
||||
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
);
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
@@ -4387,14 +4417,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||
throw new Error("--tool is claude (the default), codex or cursor");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4450,11 +4481,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error; "Skip this version" is the one
|
||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
@@ -246,10 +248,13 @@ export function UpdateNotifier() {
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
// Closing while an update applies only hides it: the apply request and the
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -261,8 +266,8 @@ export function UpdateNotifier() {
|
||||
setDismissedSha(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
// Escape closes for now — an applying update carries on in the background.
|
||||
useDialog(show, panelRef, closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
@@ -315,6 +320,7 @@ export function UpdateNotifier() {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
else setClosedKey(null); // closed while it ran: show why it failed
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
@@ -359,6 +365,8 @@ export function UpdateNotifier() {
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||
setClosedKey(null);
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
@@ -436,7 +444,6 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
|
||||
@@ -298,12 +298,17 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||
let fail: (e: Error) => void = () => {};
|
||||
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
fail(new Error("Command failed: npm run build"));
|
||||
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — 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 (`?tool=codex`), an OpenAI API key or a pasted
|
||||
* `auth.json` — 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 Claude login goes into their home at once — no restart. A profile's
|
||||
* @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
|
||||
* 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
|
||||
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
|
||||
* 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
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,22 +29,28 @@ 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, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type OmpSharingState,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
type CcUsageWindow,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
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";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -53,19 +67,81 @@ type Row = {
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
fiveHour?: CcUsageWindow | null;
|
||||
sevenDay?: CcUsageWindow | null;
|
||||
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const fromClaude = (w: CcUsageWindow | null | undefined) =>
|
||||
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
|
||||
const fromCodex = (label: string) => {
|
||||
const w = q?.windows?.find((x) => x.label === label);
|
||||
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
|
||||
};
|
||||
return (["5h", "wk"] as const).map((key) => {
|
||||
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
|
||||
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
|
||||
return {
|
||||
key,
|
||||
used: passed ? 0 : (w?.used ?? null),
|
||||
resetsAt: passed ? null : (w?.resetsAt ?? null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** 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" },
|
||||
];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
|
||||
|
||||
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
|
||||
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
|
||||
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
|
||||
return list.map((a) => ({
|
||||
...a,
|
||||
tool,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||
@@ -79,18 +155,24 @@ function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const aiTools = useAiTools();
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [omp, setOmp] = useState<OmpSharingState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||
const preset = (() => {
|
||||
const tool = params.get("poolAdd");
|
||||
const profile = params.get("poolProfile");
|
||||
return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null;
|
||||
})() as { tool: PoolTool; profile: string } | null;
|
||||
const [adding, setAdding] = useState(() => isAdmin && preset !== null);
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
// 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
|
||||
);
|
||||
const closeAdd = () => {
|
||||
setAdding(false);
|
||||
setAdding(null);
|
||||
if (!preset) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("poolAdd");
|
||||
@@ -115,16 +197,32 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setOmp)
|
||||
.catch(() => setOmp(null));
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !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;
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
@@ -142,122 +240,74 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
const count = badge(x.id);
|
||||
return (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`pool-tab-${x.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls="pool-panel"
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent text-fg-primary font-medium"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||
{x.label}
|
||||
{count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
|
||||
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
||||
<div className="text-[11px] text-fg-secondary">
|
||||
{t("pool.pinned", {
|
||||
users: pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username)
|
||||
.join(", "),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">
|
||||
{fmtCost(r.spend5h)}
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(r)}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
)}
|
||||
</div>
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
preset={preset}
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
@@ -301,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const metered = METERED.has(r.tool);
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(r.expired || r.refreshError || r.full) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.expired && (
|
||||
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<div className="text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
? "bg-status-danger"
|
||||
: left <= 10
|
||||
? "bg-status-warning"
|
||||
: "bg-accent-solid"
|
||||
}`}
|
||||
style={{ width: `${left ?? 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
|
||||
{left == null ? "—" : `${left}%`}
|
||||
</span>
|
||||
</div>
|
||||
{resets && (
|
||||
<div
|
||||
className="pl-[3.875rem] text-[10px] text-fg-muted"
|
||||
title={formatDateTime(w.resetsAt!)}
|
||||
>
|
||||
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
|
||||
*/
|
||||
function OmpPanel({
|
||||
state,
|
||||
isAdmin,
|
||||
onChange,
|
||||
}: {
|
||||
state: OmpSharingState | null;
|
||||
isAdmin: boolean;
|
||||
onChange: (s: OmpSharingState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const ts = useTranslation("settings").t;
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!state?.available) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? t("pool.omp.off")
|
||||
: !state.installed
|
||||
? ts("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? ts("ompSharing.running")
|
||||
: error
|
||||
? ts("ompSharing.failed", { error })
|
||||
: ts("ompSharing.starting");
|
||||
return (
|
||||
<div className="px-5 py-3 space-y-2">
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
|
||||
{isAdmin ? (
|
||||
<SettingsSwitchRow
|
||||
label={ts("ompSharing.switch")}
|
||||
description={ts("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onChange(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Any other tool: no pool — say so, and why for the ones we know. */
|
||||
function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
{t(`pool.none.reason.${id}`, {
|
||||
defaultValue: t("pool.none.reason.default", { tool: label }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||
* on another account of the tool moves here when ticked; unticking one sends
|
||||
@@ -439,23 +881,29 @@ type ClaudeSource = "profile" | "credentials" | "token";
|
||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||
claude: ["profile", "credentials", "token"],
|
||||
codex: ["profile", "token"],
|
||||
cursor: ["token"],
|
||||
};
|
||||
|
||||
function AddAccountDialog({
|
||||
tool,
|
||||
label,
|
||||
preset,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** The open tab's tool: the account is added to its pool. */
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
/** A saved profile to move in, from its "Move to pool" link. */
|
||||
preset?: { tool: PoolTool; profile: string } | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const sources = SOURCES[tool];
|
||||
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState(preset?.profile ?? "");
|
||||
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -463,12 +911,12 @@ function AddAccountDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles(null);
|
||||
if (!sources.includes("profile")) return;
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool]);
|
||||
}, [tool, sources]);
|
||||
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
@@ -483,11 +931,9 @@ function AddAccountDialog({
|
||||
const body: PoolAccountInput =
|
||||
source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
: tool === "claude" && source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
@@ -504,12 +950,13 @@ function AddAccountDialog({
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
// Codex's and Cursor's one non-profile source is their own kind of key.
|
||||
const secretKind = tool === "claude" ? source : tool;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
title={t("pool.addFor", { tool: label })}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
@@ -519,40 +966,27 @@ function AddAccountDialog({
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value as PoolTool);
|
||||
setProfile("");
|
||||
setSource("profile");
|
||||
}}
|
||||
aria-label={t("pool.tool")}
|
||||
{sources.length > 1 && (
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
|
||||
>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{SOURCES[tool].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tool === "codex" && s === "token"
|
||||
? t("pool.secret.codex")
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tool !== "claude" && s === "token"
|
||||
? t(`pool.secret.${tool}`)
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
@@ -592,7 +1026,7 @@ function AddAccountDialog({
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
{secretKind === "token" || secretKind === "cursor" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolGroup,
|
||||
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";
|
||||
|
||||
/** Pool tools with their tab labels, in tab order. */
|
||||
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
];
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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: "" })
|
||||
? t(`pool.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
export function PoolGroups({
|
||||
pool,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
isAdmin: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const groups = pool.groups ?? [];
|
||||
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
|
||||
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className="rounded-lg border border-border bg-surface-2 p-3"
|
||||
data-testid="pool-group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.groups.members", { count: g.users.length })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(g)}
|
||||
title={t("pool.groups.edit", { name: g.name })}
|
||||
aria-label={t("pool.groups.edit", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(g)}
|
||||
title={t("pool.groups.delete", { name: g.name })}
|
||||
aria-label={t("pool.groups.delete", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{g.users.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{g.users.map((u) => (
|
||||
<span key={u.userId} className="badge py-0 text-[11px]">
|
||||
{u.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 space-y-1">
|
||||
{POOL_TOOL_LABELS.map(({ id, label }) => {
|
||||
const names = g.accounts[id] ?? [];
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-1.5 text-[11px]">
|
||||
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
|
||||
<span className="text-fg-secondary">{label}:</span>
|
||||
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
|
||||
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<GroupDialog
|
||||
pool={pool}
|
||||
group={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.groups.saved", { name }));
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteGroupDialog
|
||||
group={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.groups.deleted", { name: deleting.name }));
|
||||
setDeleting(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a group, or edit one: its name, its members and its accounts. */
|
||||
function GroupDialog({
|
||||
pool,
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
group: PoolGroup | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [members, setMembers] = useState<Set<number>>(
|
||||
() => new Set(group?.users.map((u) => u.userId) ?? [])
|
||||
);
|
||||
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
|
||||
claude: new Set(group?.accounts.claude ?? []),
|
||||
codex: new Set(group?.accounts.codex ?? []),
|
||||
cursor: new Set(group?.accounts.cursor ?? []),
|
||||
}));
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 = <T,>(set: Set<T>, 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;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
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());
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
|
||||
message={t("pool.groups.hint")}
|
||||
confirmLabel={t("pool.save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.groups.name")}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.memberList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const other = groupOfUser(u.id);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={members.has(u.id)}
|
||||
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{other && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.groups.inGroup", { group: other.name })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.accountList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
|
||||
const names = accountNames(pool, tool);
|
||||
return (
|
||||
<div key={tool} className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
|
||||
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
{names.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{names.map((n) => (
|
||||
<Checkbox
|
||||
key={n}
|
||||
checked={accounts[tool].has(n)}
|
||||
onChange={(on) =>
|
||||
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
|
||||
}
|
||||
label={n}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.groups.delete", { name: group.name })}
|
||||
message={t("pool.groups.deleteHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -35,5 +35,6 @@
|
||||
"autoFix": "Auto fix",
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session"
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
|
||||
}
|
||||
|
||||
@@ -132,12 +132,10 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||
"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.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||
"account": "Account",
|
||||
"tool": "Tool",
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
@@ -146,12 +144,6 @@
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Name",
|
||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||
"remove": "Remove {{name}}",
|
||||
@@ -173,7 +165,8 @@
|
||||
"ENOTFOUND": "That saved login no longer exists.",
|
||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||
"ENOUSER": "That user no longer exists."
|
||||
"ENOUSER": "That user no longer exists.",
|
||||
"ENOGROUP": "That group no longer exists."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
@@ -185,7 +178,8 @@
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||
"cursor": "A Cursor API key from the Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||
@@ -197,12 +191,14 @@
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key or auth.json"
|
||||
"codex": "API key or auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||
"cursor": "Create one in the Cursor dashboard under Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "refresh failed",
|
||||
"pinned": "Used by: {{users}}",
|
||||
@@ -218,7 +214,70 @@
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved",
|
||||
"profilesLink": "Profiles in Settings"
|
||||
"profilesLink": "Profiles in Settings",
|
||||
"addFor": "Add {{tool}} account",
|
||||
"type": "Type",
|
||||
"about": {
|
||||
"claude": "New Claude Code sessions run on these accounts. A setup-token goes in as an environment variable; a real login is written into each user's home by the server and refreshed by it.",
|
||||
"codex": "New Codex sessions (not on an AI Proxy) run on these accounts. An OpenAI API key goes in as an environment variable; a ChatGPT login is written into each user's own Codex home.",
|
||||
"cursor": "New Cursor CLI sessions run on these Cursor API keys, given to the session as CURSOR_API_KEY — it outranks the user's own `cursor-agent login` for that session only. Cursor reports no usage or quota here, so the account with the fewest live sessions is picked."
|
||||
},
|
||||
"notInstalled": "{{tool}} is not installed on this server yet.",
|
||||
"emptyFor": "No {{tool}} accounts yet. Until you add one, each user's {{tool}} session needs that user's own sign-in.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "ChatGPT login"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "shared",
|
||||
"about": "Oh My Pi has no account pool: it shares this server's own omp login instead. Sign the host account in with `omp` (then /login), turn sharing on, and every user's omp session runs on it through omp's own gateway — with a key that works only while its pane runs.",
|
||||
"off": "Sharing is off: each user's omp session uses that user's own login.",
|
||||
"unavailable": "Oh My Pi has no account pool. Its login can be shared only on a server where every user has their own Linux account (server mode with per-user accounts); here each user's omp session uses that user's own login."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} has no account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity signs in with a Google account and keeps that login in the system keyring; it takes no key AgentHub could hand a session. Each user signs in to agy themselves.",
|
||||
"default": "AgentHub cannot hand {{tool}} a shared login, so each user signs in to it themselves."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} live",
|
||||
"liveCount_other": "{{count}} live",
|
||||
"savedProfiles": {
|
||||
"title": "Saved profiles you can share",
|
||||
"empty": "No saved profiles for this tool. Save one in Settings → AI tools → Profiles, then share it here.",
|
||||
"activeHere": "in use on this server",
|
||||
"share": "Share to pool",
|
||||
"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",
|
||||
"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.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{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",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
"noneInPool": "No accounts of this tool in the pool.",
|
||||
"shared": "Shared pool"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -35,5 +35,6 @@
|
||||
"autoFix": "Tự sửa",
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa"
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
|
||||
}
|
||||
|
||||
@@ -132,12 +132,10 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||
"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.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||
"account": "Tài khoản",
|
||||
"tool": "Công cụ",
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
@@ -146,12 +144,6 @@
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Tên",
|
||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||
"remove": "Xoá {{name}}",
|
||||
@@ -173,7 +165,8 @@
|
||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||
"ENOUSER": "User đó không còn tồn tại."
|
||||
"ENOUSER": "User đó không còn tồn tại.",
|
||||
"ENOGROUP": "Nhóm đó không còn tồn tại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
@@ -185,7 +178,8 @@
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||
"cursor": "Cursor API key lấy từ Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
||||
@@ -197,12 +191,14 @@
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key hoặc auth.json"
|
||||
"codex": "API key hoặc auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||
"cursor": "Tạo trong Cursor dashboard tại Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi",
|
||||
"pinned": "Đang dùng: {{users}}",
|
||||
@@ -218,7 +214,70 @@
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}",
|
||||
"profilesLink": "Hồ sơ trong Cài đặt"
|
||||
"profilesLink": "Hồ sơ trong Cài đặt",
|
||||
"addFor": "Thêm tài khoản {{tool}}",
|
||||
"type": "Loại",
|
||||
"about": {
|
||||
"claude": "Phiên Claude Code mới chạy trên các tài khoản này. Setup-token được đưa vào dưới dạng biến môi trường; login thật được server ghi vào home của từng user và tự làm mới.",
|
||||
"codex": "Phiên Codex mới (không qua AI Proxy) chạy trên các tài khoản này. OpenAI API key được đưa vào dưới dạng biến môi trường; login ChatGPT được ghi vào Codex home riêng của từng user.",
|
||||
"cursor": "Phiên Cursor CLI mới chạy trên các Cursor API key này, đưa vào phiên qua CURSOR_API_KEY — chỉ trong phiên đó nó được ưu tiên hơn `cursor-agent login` của chính user. Cursor không báo mức dùng hay quota ở đây, nên tài khoản có ít phiên đang chạy nhất sẽ được chọn."
|
||||
},
|
||||
"notInstalled": "{{tool}} chưa được cài trên server này.",
|
||||
"emptyFor": "Chưa có tài khoản {{tool}} nào. Khi chưa thêm, phiên {{tool}} của mỗi user cần chính user đó đăng nhập.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "Login ChatGPT"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "chia sẻ",
|
||||
"about": "Oh My Pi không có account pool: thay vào đó nó chia sẻ login omp của chính server. Đăng nhập tài khoản host bằng `omp` (rồi /login), bật chia sẻ, và phiên omp của mọi user chạy trên login đó qua gateway của omp — với một key chỉ dùng được khi pane còn chạy.",
|
||||
"off": "Chia sẻ đang tắt: phiên omp của mỗi user dùng login của chính user đó.",
|
||||
"unavailable": "Oh My Pi không có account pool. Login của nó chỉ chia sẻ được trên server mà mỗi user có tài khoản Linux riêng (server mode với tài khoản theo user); ở đây phiên omp của mỗi user dùng login của chính user đó."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} không có account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity đăng nhập bằng tài khoản Google và giữ login trong keyring của hệ thống; nó không nhận key nào để AgentHub đưa cho phiên. Mỗi user tự đăng nhập agy.",
|
||||
"default": "AgentHub không thể đưa cho {{tool}} một login dùng chung, nên mỗi user tự đăng nhập."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} đang chạy",
|
||||
"liveCount_other": "{{count}} đang chạy",
|
||||
"savedProfiles": {
|
||||
"title": "Profile đã lưu có thể chia sẻ",
|
||||
"empty": "Chưa có profile nào của tool này. Lưu một profile trong Settings → AI tools → Profiles rồi chia sẻ ở đây.",
|
||||
"activeHere": "đang dùng trên server này",
|
||||
"share": "Chia sẻ vào pool",
|
||||
"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",
|
||||
"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á.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{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",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
"noneInPool": "Pool chưa có tài khoản nào của tool này.",
|
||||
"shared": "Pool chung"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
|
||||
+55
-2
@@ -660,6 +660,13 @@ export type PoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The last usage reading of the account's own token, or null before one. */
|
||||
quota?: {
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
error: string | null;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -673,6 +680,11 @@ export type CodexPoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The newest `rate_limits` its sessions' rollouts carry (`5h`, `wk`, …), or null. */
|
||||
quota?: {
|
||||
windows: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
asOf: string;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -683,14 +695,37 @@ export type AccountPin = {
|
||||
account: string;
|
||||
pinnedAt?: string;
|
||||
};
|
||||
/** One Cursor pool account — a Cursor API key, never the key itself; no quota. */
|
||||
export type CursorPoolAccount = Omit<CodexPoolAccount, "kind"> & { kind: "apikey" };
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Absent from a server older than the Cursor pool. */
|
||||
cursor?: CursorPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
pins?: Partial<Record<PoolTool, AccountPin[]>>;
|
||||
/** Server mode's pool groups; absent from an older server, empty in local mode. */
|
||||
groups?: PoolGroup[];
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
/**
|
||||
* A pool group: its members (one group per user) run only on its accounts;
|
||||
* a user in no group runs on the accounts no group lists (the shared pool).
|
||||
*/
|
||||
export type PoolGroup = {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
users: { userId: number; username: string }[];
|
||||
accounts: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
/** What `PATCH /api/accounts/groups/:id` takes — every part optional. */
|
||||
export type PoolGroupPatch = {
|
||||
name?: string;
|
||||
userIds?: number[];
|
||||
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex" | "cursor";
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
@@ -1182,6 +1217,24 @@ export const api = {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
}),
|
||||
/** POST /api/accounts/groups — admin: a new, empty pool group. */
|
||||
createGroup: (name: string, currentPassword: string) =>
|
||||
request<{ group: { id: number; name: string } }>("/accounts/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
|
||||
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
|
||||
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
|
||||
deleteGroup: (id: number, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/groups/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
|
||||
@@ -84,7 +84,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,6 +54,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.19 (rebuild client to match)
|
||||
UI build v2.8.20 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -93,9 +93,9 @@ In server mode a **developer** sees only rows of the workspaces they own (`lanes
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`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); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
|
||||
**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`):
|
||||
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). With `--tool cursor` the **Cursor account pool**: `add <name> --tool cursor` (a Cursor API key, typed hidden or on stdin). `pin <account> <user>` / `unpin <user>` (add `--tool codex` or `--tool cursor`) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|developer]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of developers' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
+52
-2
@@ -535,12 +535,62 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## Cursor account pool
|
||||
|
||||
Cursor CLI sessions get a pool too (`server/lib/cursor-pool.js`): Cursor API
|
||||
keys (Cursor dashboard → Integrations → API keys), added on the Users page's
|
||||
**Account pool → Cursor CLI** tab or from the shell:
|
||||
|
||||
```bash
|
||||
agenthub accounts add cursor-team --tool cursor # key typed hidden, or piped on stdin
|
||||
agenthub accounts list --tool cursor
|
||||
agenthub accounts remove cursor-team --tool cursor
|
||||
agenthub accounts pin cursor-team mia --tool cursor
|
||||
```
|
||||
|
||||
- The key reaches the pane only as tmux `-e CURSOR_API_KEY=…` (a member's
|
||||
launch file under S3), which `cursor-agent` prefers over the account's own
|
||||
`cursor-agent login` for that process only. Nothing is written into a home.
|
||||
- Cursor reports no usage or quota to AgentHub, so the account with the fewest
|
||||
live Cursor panes is picked.
|
||||
|
||||
> **Ceiling:** a pane can read its own key (`$CURSOR_API_KEY`).
|
||||
|
||||
## 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,
|
||||
`server/lib/pool-groups.js`):
|
||||
|
||||
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||
Cursor). Its members' new sessions run only on its accounts of that tool; a
|
||||
group with no account of a tool leaves its members on their own sign-in.
|
||||
- A user belongs to at most one group (ticking them in another moves them).
|
||||
An account may serve several groups.
|
||||
- A user in no group runs on the **shared pool**: the accounts no group lists.
|
||||
With no group at all, everything is shared, as before.
|
||||
- A pin (Edit on an account) is an admin's explicit choice and wins over the
|
||||
group.
|
||||
- A login already live in a member's home stays until their panes end (one
|
||||
`.credentials.json` / `auth.json` per home), even after a group change.
|
||||
- Each change needs the admin's own password again and is audited. Removing
|
||||
an account takes it out of every group; deleting a group sends its members
|
||||
back to the shared pool and removes no account.
|
||||
|
||||
The tab bar also shows Oh My Pi (its login sharing, below) and every other
|
||||
installed tool, which says it has no pool (Antigravity keeps its Google login
|
||||
in the system keyring; there is no key AgentHub could hand a session).
|
||||
|
||||
The Claude and Codex tabs list the saved profiles of Settings → AI tools →
|
||||
Profiles as cards; **Share to pool** moves one in (the profile leaves the
|
||||
switcher, as with **Move to pool** in Settings).
|
||||
|
||||
## Oh My Pi login sharing
|
||||
|
||||
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
||||
login. An admin can let every member's omp run on the HOST account's omp
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
|
||||
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing**, or the
|
||||
**Oh My Pi** tab of the Users page's Account pool (off by default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
broker and gateway (`server/lib/omp-share.js`):
|
||||
|
||||
```bash
|
||||
|
||||
+229
-4
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.19
|
||||
version: 2.8.20
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -4632,6 +4632,36 @@ components:
|
||||
description: The pool account's name
|
||||
pinnedAt:
|
||||
type: string
|
||||
PoolGroup:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- users
|
||||
- accounts
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
accounts:
|
||||
type: object
|
||||
description: Per tool (claude, codex, cursor), the pool account names it may use
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
UserPrefs:
|
||||
type: object
|
||||
required:
|
||||
@@ -15227,7 +15257,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: listPoolAccounts
|
||||
summary: The Claude and Codex account pools as the balancer sees them (never a key or token)
|
||||
summary: The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)
|
||||
parameters:
|
||||
- name: force
|
||||
in: query
|
||||
@@ -15289,6 +15319,31 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AccountPin'
|
||||
cursor:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AccountPin'
|
||||
groups:
|
||||
type: array
|
||||
description: 'Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group''s accounts, a user in no group on the accounts no group lists'
|
||||
items:
|
||||
$ref: '#/components/schemas/PoolGroup'
|
||||
cursor:
|
||||
type: array
|
||||
description: 'The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend'
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- apikey
|
||||
addedAt:
|
||||
type: string
|
||||
livePanes:
|
||||
type: integer
|
||||
codex:
|
||||
type: array
|
||||
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
||||
@@ -15326,7 +15381,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: addPoolAccount
|
||||
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back'
|
||||
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -15342,6 +15397,7 @@ paths:
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
name:
|
||||
type: string
|
||||
description: Defaults to the profile's name
|
||||
@@ -15353,7 +15409,7 @@ paths:
|
||||
description: 'Claude: the text of a .credentials.json'
|
||||
token:
|
||||
type: string
|
||||
description: 'Claude: a setup-token; Codex: an API key or auth.json text'
|
||||
description: 'Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
@@ -15411,6 +15467,7 @@ paths:
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
userId:
|
||||
type: integer
|
||||
account:
|
||||
@@ -15482,6 +15539,7 @@ paths:
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
@@ -15515,6 +15573,172 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/groups:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: createPoolGroup
|
||||
summary: 'Admin: a new, empty pool group, with their own password again'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- currentPassword
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: '`{group: {id, name}}`'
|
||||
'400':
|
||||
description: EBADNAME
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: EEXISTS — a group has that name
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/groups/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: updatePoolGroup
|
||||
summary: 'Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
accounts:
|
||||
type: object
|
||||
description: '`{tool: [account names]}` for claude, codex, cursor'
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{group}`'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
group:
|
||||
$ref: '#/components/schemas/PoolGroup'
|
||||
'400':
|
||||
description: EBADNAME, EBADUSER, EBADTOOL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOGROUP, ENOUSER, ENOACCOUNT
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: EEXISTS — a group has that name
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: deletePoolGroup
|
||||
summary: 'Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{ok: true}`'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOGROUP
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/{name}:
|
||||
delete:
|
||||
tags:
|
||||
@@ -15542,6 +15766,7 @@ paths:
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.19",
|
||||
"version": "2.8.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.19",
|
||||
"version": "2.8.20",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.19",
|
||||
"version": "2.8.20",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file cursor-pool.test.js
|
||||
* @description The Cursor account pool (server/lib/cursor-pool.js), fake keys
|
||||
* only: key validation and file modes, the balancer (fewest live Cursor panes,
|
||||
* a pin wins), the key reaching a `cursor-agent` pane only through tmux `-e
|
||||
* CURSOR_API_KEY` and never its argv, the account recorded on the run, pins
|
||||
* for `cursor` and the account_pins rebuild that drops the old tool CHECK, and
|
||||
* an empty pool leaving every spawn as it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cursor-pool-test-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
|
||||
const { db } = require("../db");
|
||||
const pool = require("../lib/cursor-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
let tmuxArgv;
|
||||
|
||||
const envOf = (argv, name) =>
|
||||
argv
|
||||
.slice(0, argv.indexOf("--"))
|
||||
.find((x) => String(x).startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
|
||||
function run(id, account, status = "running") {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, tool)
|
||||
VALUES (?, 'interactive', '/tmp', ?, ?, ?, 'cursor')`
|
||||
).run(id, status, new Date().toISOString(), account);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") tmuxArgv = args;
|
||||
return "";
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__setExecImpl(null);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
tmuxArgv = null;
|
||||
});
|
||||
|
||||
describe("cursor pool: accounts", () => {
|
||||
it("stores a key 0600 in a 0700 dir and never lists it", () => {
|
||||
const added = pool.addAccount("team", { apiKey: KEY("a") });
|
||||
assert.deepEqual(Object.keys(added).sort(), ["addedAt", "kind", "name"]);
|
||||
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
|
||||
assert.equal(fs.statSync(path.join(pool.poolDir(), "team.json")).mode & 0o777, 0o600);
|
||||
const listed = pool.poolStatus();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.ok(!JSON.stringify(listed).includes(KEY("a")));
|
||||
assert.equal(listed[0].quotaPercent, null);
|
||||
});
|
||||
|
||||
it("refuses a bad name, a bad key and a taken name", () => {
|
||||
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: "short" }), { code: "EBADTOKEN" });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: `${KEY("a")}\nX=1` }), {
|
||||
code: "EBADTOKEN",
|
||||
});
|
||||
pool.addAccount("x", { apiKey: KEY("a") });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: KEY("b") }), { code: "EEXISTS" });
|
||||
pool.removeAccount("x");
|
||||
assert.throws(() => pool.removeAccount("x"), { code: "ENOACCOUNT" });
|
||||
});
|
||||
|
||||
it("picks the account with the fewest live panes", () => {
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
pool.addAccount("b", { apiKey: KEY("b") });
|
||||
run("r1", "a");
|
||||
run("r2", "b", "completed");
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor pool: spawn", () => {
|
||||
it("an empty pool leaves a Cursor spawn exactly as before", () => {
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor" });
|
||||
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), undefined);
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, null);
|
||||
});
|
||||
|
||||
it("the key reaches the pane through tmux -e only, and the run names its account", () => {
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor", initialPrompt: "hi" });
|
||||
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), KEY("a"));
|
||||
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
|
||||
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, "a");
|
||||
|
||||
// Other tools never get it.
|
||||
tmuxArgv = null;
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
|
||||
assert.ok(!tmuxArgv.some((x) => String(x).includes("CURSOR_API_KEY")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor pool: pins", () => {
|
||||
it("account_pins takes a cursor pin, and removing the account drops it", () => {
|
||||
const sql = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||
.get().sql;
|
||||
assert.ok(!sql.includes("CHECK(tool IN"), "no tool CHECK left");
|
||||
assert.ok(pins.TOOLS.includes("cursor"));
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('pinned', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
db.prepare("INSERT INTO account_pins (user_id, tool, account) VALUES (?, 'cursor', 'a')").run(
|
||||
uid
|
||||
);
|
||||
assert.equal(pins.listPins("cursor").length, 1);
|
||||
pool.removeAccount("a");
|
||||
assert.equal(pins.listPins("cursor").length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file pool-groups.test.js
|
||||
* @description Account pool groups (server/lib/pool-groups.js and the
|
||||
* `/api/accounts/groups` routes), fake accounts only: with no group every
|
||||
* account is shared as before; a group's member gets only the group's
|
||||
* accounts of each tool (none → no pool account), a user in no group only
|
||||
* the accounts no group lists, a pin wins over the group; one group per user;
|
||||
* a removed account or deleted group leaves no rows behind; and the routes
|
||||
* need an admin's own password again, are audited and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-groups-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
let server;
|
||||
let BASE;
|
||||
let MIA;
|
||||
let LEO;
|
||||
let ZOE;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
ZOE = (await auth.createUser("zoe", "member-pass-3", "developer")).id;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM pool_groups").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||
});
|
||||
|
||||
describe("pool groups: who may take which account", () => {
|
||||
it("shares every account while no group exists", () => {
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||
assert.ok(["a", "b", "c"].every(groups.allowed(MIA, "cursor")));
|
||||
});
|
||||
|
||||
it("gives a member only the group's accounts, the rest only the shared ones", () => {
|
||||
const g = groups.createGroup("Team A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a", "b is no longer shared");
|
||||
// An account may serve several groups; a group with no account of a tool
|
||||
// leaves its members on their own sign-in.
|
||||
const h = groups.createGroup("Team B");
|
||||
groups.setGroupUsers(h.id, [ZOE]);
|
||||
assert.equal(cursorPool.pickAccount(ZOE), null);
|
||||
groups.setGroupAccounts(h.id, "cursor", ["b", "c"]);
|
||||
assert.ok(["b", "c"].includes(cursorPool.pickAccount(ZOE).name));
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||
// Claude follows the same groups.
|
||||
claudePool.addAccount("shared", TOKEN("s"));
|
||||
claudePool.addAccount("team", TOKEN("t"));
|
||||
groups.setGroupAccounts(g.id, "claude", ["team"]);
|
||||
assert.equal(claudePool.pickAccount(null, MIA).name, "team");
|
||||
assert.equal(claudePool.pickAccount(null, LEO).name, "shared");
|
||||
});
|
||||
|
||||
it("puts a user in one group only, and honours a pin outside it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
const h = groups.createGroup("B");
|
||||
groups.setGroupUsers(g.id, [MIA, LEO]);
|
||||
groups.setGroupUsers(h.id, [MIA]);
|
||||
const list = groups.listGroups();
|
||||
assert.deepEqual(
|
||||
list.find((x) => x.name === "A").users.map((u) => u.username),
|
||||
["leo"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.find((x) => x.name === "B").users.map((u) => u.username),
|
||||
["mia"]
|
||||
);
|
||||
groups.setGroupAccounts(h.id, "cursor", ["b"]);
|
||||
pins.setPin(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
});
|
||||
|
||||
it("drops a removed account from its groups, and a group's rows with it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
cursorPool.removeAccount("b");
|
||||
assert.deepEqual(groups.listGroups()[0].accounts.cursor, []);
|
||||
groups.deleteGroup(g.id);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM pool_group_users").get().n, 0);
|
||||
assert.throws(() => groups.setGroupAccounts(g.id, "cursor", ["a"]), { code: "ENOGROUP" });
|
||||
assert.throws(() => groups.createGroup(""), { code: "EBADNAME" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pool groups: routes", () => {
|
||||
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const bad = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EREAUTH");
|
||||
const made = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
const id = made.body.group.id;
|
||||
const dup = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "team a", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(dup.body.error.code, "EEXISTS");
|
||||
const edit = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: {
|
||||
name: "Team Alpha",
|
||||
userIds: [MIA],
|
||||
accounts: { cursor: ["a"] },
|
||||
currentPassword: BOSS_PASS,
|
||||
},
|
||||
});
|
||||
assert.equal(edit.status, 200);
|
||||
assert.equal(edit.body.group.name, "Team Alpha");
|
||||
assert.deepEqual(edit.body.group.accounts.cursor, ["a"]);
|
||||
const missing = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { accounts: { cursor: ["nope"] }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(missing.body.error.code, "ENOACCOUNT");
|
||||
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||
assert.equal(list.body.groups[0].users[0].username, "mia");
|
||||
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||
assert.match(audit.path, /group Team Alpha cursor accounts/);
|
||||
const dev = await login("leo", "member-pass-2");
|
||||
const refused = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: dev,
|
||||
body: { currentPassword: "member-pass-2" },
|
||||
});
|
||||
assert.equal(refused.status, 403);
|
||||
const del = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(del.status, 200);
|
||||
const gone = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file server-mode-account-pool.test.js
|
||||
* @description The Claude/Codex account pool from the Users page: only an
|
||||
* @description The Claude/Codex/Cursor account pool from the Users page: only an
|
||||
* admin may add or remove an account, each change needs the admin's OWN
|
||||
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||
* list) ever carries it — the audit log names the account, not its secret, and
|
||||
@@ -25,6 +25,7 @@ const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
|
||||
// The quota read would go to the network; the pool's list must not.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
@@ -113,6 +114,30 @@ describe("the account pool from the Users page", () => {
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
|
||||
it("adds a Cursor API key, lists it under `cursor` and removes it", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const CKEY = `key_${"c".repeat(40)}`;
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "cursor", name: "cu-1", token: CKEY, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
assert.equal(cursorPool.__read("cu-1").key, CKEY);
|
||||
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||
assert.deepEqual(
|
||||
list.body.cursor.map((a) => a.name),
|
||||
["cu-1"]
|
||||
);
|
||||
assert.deepEqual(list.body.pins.cursor, []);
|
||||
assert.ok(!JSON.stringify(list.body).includes(CKEY));
|
||||
const del = await call("DELETE", "/api/accounts/cu-1", {
|
||||
cookie: boss,
|
||||
body: { tool: "cursor", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(del.status, 200);
|
||||
assert.equal(cursorPool.__read("cu-1"), null);
|
||||
});
|
||||
|
||||
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = (body) =>
|
||||
@@ -123,7 +148,7 @@ describe("the account pool from the Users page", () => {
|
||||
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
|
||||
assert.equal((await add({ tool: "antigravity" })).body.error.code, "EBADTOOL");
|
||||
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
|
||||
+49
-2
@@ -1958,16 +1958,63 @@ for (const [col, type] of [
|
||||
}
|
||||
// An admin's pin of one server-mode user to one pool account per tool
|
||||
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||
// The account is named, not referenced — the pool lives in files.
|
||||
// The account is named, not referenced — the pool lives in files. The tool
|
||||
// list is account-pins.js's `TOOLS`, not a CHECK (a new pool needs no rebuild).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_pins (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||
// may serve several groups. Accounts no group lists are the shared pool.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pool_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pool_group_users (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pool_group_accounts (
|
||||
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, tool, account)
|
||||
);
|
||||
`);
|
||||
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||
// claude/codex. Rebuilt with every pin kept.
|
||||
{
|
||||
const meta = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||
.get();
|
||||
if (meta?.sql?.includes("CHECK(tool IN")) {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
CREATE TABLE account_pins_new (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
INSERT INTO account_pins_new (user_id, tool, account, pinned_at)
|
||||
SELECT user_id, tool, account, pinned_at FROM account_pins;
|
||||
DROP TABLE account_pins;
|
||||
ALTER TABLE account_pins_new RENAME TO account_pins;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// An existing database made before auto_vacuum: the setting is stored only by
|
||||
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file account-pins.js
|
||||
* @description Account pins: an admin's choice that one server-mode user's
|
||||
* new sessions of a tool run on one named pool account instead of the one the
|
||||
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
|
||||
* balancer would pick (claude-pool.js / codex-pool.js / cursor-pool.js `pick`) — the Users
|
||||
* page's "switch a user's account". One pin per user and tool
|
||||
* (`account_pins`); the user is the workspace's OWNER
|
||||
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
|
||||
@@ -19,14 +19,15 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const TOOLS = ["claude", "codex"];
|
||||
const TOOLS = ["claude", "codex", "cursor"];
|
||||
const POOLS = { claude: "./claude-pool", codex: "./codex-pool", cursor: "./cursor-pool" };
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const db = () => require("../db").db;
|
||||
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
|
||||
const poolOf = (tool) => require(POOLS[tool]);
|
||||
|
||||
function validTool(tool) {
|
||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
|
||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${TOOLS.join(", ")}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -132,13 +133,23 @@ async function applyPin(userId, tool) {
|
||||
return poolOf(tool).switchHome(member, pin);
|
||||
}
|
||||
|
||||
/** A removed account takes its pins with it. */
|
||||
/** A removed account takes its pins — and its place in every group — with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing pinned */
|
||||
}
|
||||
require("./pool-groups").dropAccount(tool, account);
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
|
||||
module.exports = {
|
||||
TOOLS,
|
||||
poolOf,
|
||||
pinFor,
|
||||
listPins,
|
||||
setPin,
|
||||
setAccountUsers,
|
||||
applyPin,
|
||||
dropAccount,
|
||||
};
|
||||
|
||||
@@ -503,6 +503,10 @@ function pick(member, ownerId = null) {
|
||||
live = liveLogin ? placed || liveLogin : null;
|
||||
}
|
||||
}
|
||||
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||
// in their home stays: one .credentials.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
// while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||
|
||||
@@ -358,6 +358,10 @@ function pick(member, ownerId = null) {
|
||||
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||
}
|
||||
}
|
||||
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||
// in their home stays: one auth.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "codex");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||
// waits while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file cursor-pool.js
|
||||
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
|
||||
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
|
||||
* cursor`, or the Users page), and the balancer that gives every new
|
||||
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
|
||||
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
|
||||
* it from there and it outranks the account's own `cursor-agent login` for
|
||||
* that process only; nothing is written into anyone's home. An empty pool
|
||||
* changes nothing: panes run on their home's own login as before.
|
||||
*
|
||||
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
|
||||
* 0700, file 0600); no route returns a key.
|
||||
*
|
||||
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
|
||||
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
|
||||
* there is nothing else to rank by. A user an admin pinned to one account
|
||||
* (account-pins.js) skips the ranking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
// A Cursor API key is one opaque token; never whitespace, never a newline
|
||||
// (it travels as an env value through tmux and a member's launch file).
|
||||
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
|
||||
const KEY_ENV = "CURSOR_API_KEY";
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
|
||||
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name || "").trim();
|
||||
if (!NAME_RE.test(n))
|
||||
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||||
return n;
|
||||
}
|
||||
|
||||
function readAccount(name) {
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||||
} catch {
|
||||
/* absent or not ours */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function allAccounts() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n))
|
||||
.map(readAccount)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
|
||||
function addAccount(name, { apiKey } = {}) {
|
||||
const n = validName(name);
|
||||
const key = String(apiKey || "").trim();
|
||||
if (!KEY_RE.test(key)) {
|
||||
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
|
||||
}
|
||||
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
|
||||
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(poolDir(), 0o700);
|
||||
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
|
||||
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||||
}
|
||||
|
||||
function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("cursor", n);
|
||||
}
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||||
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
/** Every account with what the balancer sees — never a key. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => ({
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
|
||||
quota: null,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The record a new Cursor pane should run on, or null (empty pool). */
|
||||
function pick(ownerId = null) {
|
||||
// The owner's group's accounts only (pool-groups.js).
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||||
const usable = allAccounts().filter((a) => allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned) return pinned;
|
||||
return usable
|
||||
.map((a) => ({ a, n: livePanes(a.name) }))
|
||||
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
|
||||
function pickAccount(ownerId = null) {
|
||||
const a = pick(ownerId);
|
||||
return a ? { name: a.name, kind: a.kind } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
|
||||
* goes through tmux `-e` (a member's launch file), never argv.
|
||||
*/
|
||||
function forSpawn(ownerId = null) {
|
||||
const a = pick(ownerId);
|
||||
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
|
||||
}
|
||||
|
||||
/** Is `name` an account in the pool? */
|
||||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||
|
||||
module.exports = {
|
||||
hasAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
poolDir,
|
||||
__read: readAccount,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file pool-groups.js
|
||||
* @description Account pool GROUPS (server mode): an admin splits the pool so
|
||||
* each team's sessions run on that team's own accounts. A group is a name, its
|
||||
* members (one group per user, `pool_group_users`) and the pool accounts it
|
||||
* may use, of every tool (`pool_group_accounts`; one account may serve many
|
||||
* groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask
|
||||
* `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may
|
||||
* take: a member of a group gets only that group's accounts of the tool —
|
||||
* none means the user's own sign-in, as with an empty pool — and a user in no
|
||||
* group gets the SHARED pool, the accounts no group lists. With no group at
|
||||
* all every account is shared, exactly as before groups existed. A pin
|
||||
* (account-pins.js) is an admin's explicit choice and is honoured whatever the
|
||||
* group. Local mode has no users: everything is shared there.
|
||||
*
|
||||
* Accounts are named, not referenced — the pool lives in files — so removing
|
||||
* an account drops its group rows (`dropAccount`); deleting a user or a group
|
||||
* cascades.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const db = () => require("../db").db;
|
||||
const pins = () => require("./account-pins");
|
||||
|
||||
const NAME_MAX = 64;
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name ?? "").trim();
|
||||
if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) {
|
||||
throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function groupRow(id) {
|
||||
const g = Number.isInteger(id)
|
||||
? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id)
|
||||
: null;
|
||||
if (!g) throw err("ENOGROUP", `no group ${id}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Every group with its members and accounts per tool. */
|
||||
function listGroups() {
|
||||
const groups = db()
|
||||
.prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name")
|
||||
.all();
|
||||
const users = db()
|
||||
.prepare(
|
||||
`SELECT g.group_id AS groupId, u.id AS userId, u.username
|
||||
FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username`
|
||||
)
|
||||
.all();
|
||||
const accounts = db()
|
||||
.prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account")
|
||||
.all();
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
users: users
|
||||
.filter((u) => u.groupId === g.id)
|
||||
.map(({ userId, username }) => ({ userId, username })),
|
||||
accounts: Object.fromEntries(
|
||||
pins().TOOLS.map((t) => [
|
||||
t,
|
||||
accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account),
|
||||
])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function createGroup(name) {
|
||||
const n = validName(name);
|
||||
try {
|
||||
const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid;
|
||||
return { id: Number(id), name: n };
|
||||
} catch (e) {
|
||||
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function renameGroup(id, name) {
|
||||
const g = groupRow(id);
|
||||
const n = validName(name);
|
||||
try {
|
||||
db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id);
|
||||
} catch (e) {
|
||||
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||
throw e;
|
||||
}
|
||||
return { id: g.id, name: n };
|
||||
}
|
||||
|
||||
/** Its members go back to the shared pool; its accounts become shared if no other group lists them. */
|
||||
function deleteGroup(id) {
|
||||
const g = groupRow(id);
|
||||
db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id);
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly `userIds` are the group's members afterwards — a user in another
|
||||
* group moves here (one group per user). One transaction.
|
||||
*/
|
||||
function setGroupUsers(id, userIds) {
|
||||
const g = groupRow(id);
|
||||
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
|
||||
throw err("EBADUSER", "userIds: an array of user ids");
|
||||
}
|
||||
const want = [...new Set(userIds)];
|
||||
for (const uid of want) {
|
||||
if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid))
|
||||
throw err("ENOUSER", `no user ${uid}`);
|
||||
}
|
||||
db().transaction(() => {
|
||||
db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id);
|
||||
const put = db().prepare(
|
||||
`INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id`
|
||||
);
|
||||
for (const uid of want) put.run(uid, g.id);
|
||||
})();
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Exactly `names` are the group's accounts of `tool` afterwards. */
|
||||
function setGroupAccounts(id, tool, names) {
|
||||
const g = groupRow(id);
|
||||
if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`);
|
||||
if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) {
|
||||
throw err("EBADNAME", "accounts: an array of account names");
|
||||
}
|
||||
const want = [...new Set(names)];
|
||||
const pool = pins().poolOf(tool);
|
||||
for (const n of want) {
|
||||
if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`);
|
||||
}
|
||||
db().transaction(() => {
|
||||
db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool);
|
||||
const put = db().prepare(
|
||||
"INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (const n of want) put.run(g.id, tool, n);
|
||||
})();
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a
|
||||
* predicate on the account name: their group's, else the shared ones; their
|
||||
* pin always. Everything when groups do not apply (local mode, no group yet,
|
||||
* or no database to ask).
|
||||
*/
|
||||
function allowed(ownerId, tool) {
|
||||
if (!require("./auth").isServerMode()) return () => true;
|
||||
try {
|
||||
if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true;
|
||||
const pin = pins().pinFor(ownerId, tool);
|
||||
const group = Number.isInteger(ownerId)
|
||||
? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId)
|
||||
: null;
|
||||
const names = new Set(
|
||||
(group
|
||||
? db()
|
||||
.prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?")
|
||||
.all(group.group_id, tool)
|
||||
: []
|
||||
).map((r) => r.account)
|
||||
);
|
||||
const grouped = group
|
||||
? null
|
||||
: new Set(
|
||||
db()
|
||||
.prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?")
|
||||
.all(tool)
|
||||
.map((r) => r.account)
|
||||
);
|
||||
return (name) => name === pin || (group ? names.has(name) : !grouped.has(name));
|
||||
} catch {
|
||||
return () => true; // a spawn never fails over a group lookup
|
||||
}
|
||||
}
|
||||
|
||||
/** A removed account leaves every group. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db()
|
||||
.prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?")
|
||||
.run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing grouped */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listGroups,
|
||||
createGroup,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
setGroupUsers,
|
||||
setGroupAccounts,
|
||||
allowed,
|
||||
dropAccount,
|
||||
};
|
||||
@@ -488,6 +488,8 @@ function spawnRun(args) {
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`.
|
||||
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, with a key already listed there.
|
||||
@@ -611,7 +613,7 @@ function spawnRun(args) {
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
account: (poolAccount || codexPool)?.name ?? null,
|
||||
account: (poolAccount || codexPool || cursorPool)?.name ?? null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
@@ -668,6 +670,7 @@ function spawnRun(args) {
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? poolAccount.env : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
...(cursorPool ? cursorPool.env : {}),
|
||||
...(ompShare ? ompShare.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* - GET /api/auth/me (Auth)
|
||||
* - POST /api/auth/login (Auth)
|
||||
* - POST /api/auth/logout (Auth)
|
||||
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
||||
* - GET /api/accounts (Auth) — the Claude, Codex and Cursor account pools and
|
||||
* the pool groups, no keys or tokens
|
||||
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
||||
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
||||
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
||||
* - POST /api/accounts/groups, PATCH/DELETE /api/accounts/groups/{id} (Auth) —
|
||||
* pool groups: a team's members run only on the group's accounts
|
||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
@@ -43,6 +46,27 @@ const schemas = {
|
||||
pinnedAt: { type: "string" },
|
||||
},
|
||||
},
|
||||
PoolGroup: {
|
||||
type: "object",
|
||||
required: ["id", "name", "users", "accounts"],
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
createdAt: { type: "string" },
|
||||
users: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { userId: { type: "integer" }, username: { type: "string" } },
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
type: "object",
|
||||
description: "Per tool (claude, codex, cursor), the pool account names it may use",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
UserPrefs: {
|
||||
type: "object",
|
||||
required: ["prefs", "onboardedAt"],
|
||||
@@ -636,7 +660,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "listPoolAccounts",
|
||||
summary:
|
||||
"The Claude and Codex account pools as the balancer sees them (never a key or token)",
|
||||
"The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)",
|
||||
parameters: [
|
||||
{
|
||||
name: "force",
|
||||
@@ -681,6 +705,27 @@ const paths = {
|
||||
properties: {
|
||||
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
cursor: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: "array",
|
||||
description:
|
||||
"Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group's accounts, a user in no group on the accounts no group lists",
|
||||
items: { $ref: "#/components/schemas/PoolGroup" },
|
||||
},
|
||||
cursor: {
|
||||
type: "array",
|
||||
description:
|
||||
"The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: { type: "string", enum: ["apikey"] },
|
||||
addedAt: { type: "string" },
|
||||
livePanes: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
@@ -715,7 +760,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "addPoolAccount",
|
||||
summary:
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -724,7 +769,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
name: { type: "string", description: "Defaults to the profile's name" },
|
||||
profile: {
|
||||
type: "string",
|
||||
@@ -736,7 +781,8 @@ const paths = {
|
||||
},
|
||||
token: {
|
||||
type: "string",
|
||||
description: "Claude: a setup-token; Codex: an API key or auth.json text",
|
||||
description:
|
||||
"Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key",
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
@@ -770,7 +816,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "userId", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userId: { type: "integer" },
|
||||
account: { type: "string", nullable: true },
|
||||
currentPassword: { type: "string" },
|
||||
@@ -816,7 +862,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "userIds", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
@@ -836,6 +882,105 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/groups": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "createPoolGroup",
|
||||
summary: "Admin: a new, empty pool group, with their own password again",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "currentPassword"],
|
||||
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: "`{group: {id, name}}`" },
|
||||
400: err("EBADNAME"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/groups/{id}": {
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
operationId: "updatePoolGroup",
|
||||
summary:
|
||||
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
accounts: {
|
||||
type: "object",
|
||||
description: "`{tool: [account names]}` for claude, codex, cursor",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{group}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { group: { $ref: "#/components/schemas/PoolGroup" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADNAME, EBADUSER, EBADTOOL"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
operationId: "deletePoolGroup",
|
||||
summary:
|
||||
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{ok: true}`" },
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/{name}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
@@ -850,7 +995,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
|
||||
+99
-19
@@ -3,8 +3,9 @@
|
||||
* @description `GET /api/accounts` — the Claude account pool as the balancer
|
||||
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
||||
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
||||
* key or a token. `?force=1` re-reads every quota first.
|
||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js), and
|
||||
* `cursor` for the Cursor pool (server/lib/cursor-pool.js: API keys only, no
|
||||
* quota). Never a key or a token. `?force=1` re-reads every quota first.
|
||||
*
|
||||
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
||||
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
||||
@@ -15,13 +16,17 @@
|
||||
* live login and the host's home becomes one more placement the server
|
||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`); a Cursor
|
||||
* one is a Cursor API key (`token`).
|
||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
||||
* login goes into their home at once, so running panes move without a
|
||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* lists the pins under `pins`. Pool GROUPS (pool-groups.js) split the pool by
|
||||
* team: `POST /groups` (`{name}`), `PATCH /groups/:id` (`{name?, userIds?,
|
||||
* accounts?: {tool: [names]}}`), `DELETE /groups/:id`; `GET /` lists them
|
||||
* under `groups`.
|
||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
||||
* can be moved in (identity only). Like every Users-page change
|
||||
@@ -34,11 +39,13 @@
|
||||
const { Router } = require("express");
|
||||
const pool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const toolProfiles = require("../lib/tool-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
const groups = require("../lib/pool-groups");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,9 +56,12 @@ router.get("/", async (req, res) => {
|
||||
res.json({
|
||||
accounts: pool.poolStatus(),
|
||||
codex: codexPool.poolStatus(),
|
||||
cursor: cursorPool.poolStatus(),
|
||||
pins: auth.isServerMode()
|
||||
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
|
||||
: { claude: [], codex: [] },
|
||||
? Object.fromEntries(pins.TOOLS.map((t) => [t, pins.listPins(t)]))
|
||||
: Object.fromEntries(pins.TOOLS.map((t) => [t, []])),
|
||||
// Server mode's pool groups (pool-groups.js); none in local mode.
|
||||
groups: auth.isServerMode() ? groups.listGroups() : [],
|
||||
blockPercent: pool.BLOCK_PERCENT,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -98,6 +108,7 @@ const STATUS = {
|
||||
EBADTOKEN: 400,
|
||||
ENOACCOUNT: 404,
|
||||
ENOUSER: 404,
|
||||
ENOGROUP: 404,
|
||||
EEXISTS: 409,
|
||||
ETOOMANY: 429,
|
||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||
@@ -105,7 +116,7 @@ const STATUS = {
|
||||
};
|
||||
|
||||
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||
function poolChange(handler) {
|
||||
function poolChange(handler, { anyTool = false } = {}) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
if (!auth.isServerMode() || !req.user) {
|
||||
@@ -114,8 +125,8 @@ function poolChange(handler) {
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
if (body.tool !== "claude" && body.tool !== "codex") {
|
||||
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
|
||||
if (!anyTool && !pins.TOOLS.includes(body.tool)) {
|
||||
throw Object.assign(new Error(`tool: ${pins.TOOLS.join(", ")}`), { code: "EBADTOOL" });
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
@@ -230,15 +241,17 @@ router.post(
|
||||
"/",
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||
: pool.addAccount(body.name, body.token);
|
||||
body.tool === "cursor"
|
||||
? cursorPool.addAccount(body.name, { apiKey: body.token })
|
||||
: body.tool === "codex"
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||
: pool.addAccount(body.name, body.token);
|
||||
return {
|
||||
status: 201,
|
||||
json: { account },
|
||||
@@ -247,6 +260,71 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// ── Groups: each team's members run on the team's own accounts ─────────────
|
||||
|
||||
router.post(
|
||||
"/groups",
|
||||
poolChange(
|
||||
async (_req, body) => {
|
||||
const g = groups.createGroup(body.name);
|
||||
return { status: 201, json: { group: g }, action: `pool: create group ${g.name}` };
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* One group's edit, each part optional: `name`, `userIds` (exactly its members
|
||||
* afterwards — one group per user, so a user in another group moves here),
|
||||
* `accounts` (`{tool: [names]}`, exactly its accounts of each tool named).
|
||||
*/
|
||||
router.patch(
|
||||
"/groups/:id",
|
||||
poolChange(
|
||||
async (req, body) => {
|
||||
const id = Number(req.params.id);
|
||||
let g = groups.listGroups().find((x) => x.id === id);
|
||||
if (!g) throw Object.assign(new Error(`no group ${req.params.id}`), { code: "ENOGROUP" });
|
||||
const audits = [];
|
||||
if (body.name !== undefined && body.name !== g.name) {
|
||||
groups.renameGroup(id, body.name);
|
||||
audits.push({ action: `pool: rename group ${g.name} to ${String(body.name).trim()}` });
|
||||
}
|
||||
const name = body.name !== undefined ? String(body.name).trim() : g.name;
|
||||
if (body.userIds !== undefined) {
|
||||
groups.setGroupUsers(id, body.userIds);
|
||||
audits.push({ action: `pool: group ${name} members ${JSON.stringify(body.userIds)}` });
|
||||
}
|
||||
if (body.accounts !== undefined) {
|
||||
if (!body.accounts || typeof body.accounts !== "object" || Array.isArray(body.accounts)) {
|
||||
throw Object.assign(new Error("accounts: {tool: [names]}"), { code: "EBADNAME" });
|
||||
}
|
||||
for (const [tool, names] of Object.entries(body.accounts)) {
|
||||
groups.setGroupAccounts(id, tool, names);
|
||||
audits.push({ action: `pool: group ${name} ${tool} accounts ${JSON.stringify(names)}` });
|
||||
}
|
||||
}
|
||||
g = groups.listGroups().find((x) => x.id === id);
|
||||
return {
|
||||
json: { group: g },
|
||||
audits: audits.length ? audits : [{ action: `pool: group ${name}` }],
|
||||
};
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/groups/:id",
|
||||
poolChange(
|
||||
async (req) => {
|
||||
const g = groups.deleteGroup(Number(req.params.id));
|
||||
return { json: { ok: true }, action: `pool: delete group ${g.name}` };
|
||||
},
|
||||
{ anyTool: true }
|
||||
)
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/pins",
|
||||
poolChange(async (_req, body) => {
|
||||
@@ -302,7 +380,9 @@ router.put(
|
||||
router.delete(
|
||||
"/:name",
|
||||
poolChange((req, body) => {
|
||||
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
|
||||
({ claude: pool, codex: codexPool, cursor: cursorPool })[body.tool].removeAccount(
|
||||
req.params.name
|
||||
);
|
||||
return {
|
||||
json: { ok: true },
|
||||
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
||||
|
||||
Reference in New Issue
Block a user