feat(settings): pool accounts are rows of Profiles; the separate Pool account section is gone

Settings → AI tools → Profiles now lists the account-pool accounts the user
may use (GET /api/me/pool-accounts) as rows of each tool's tab, with quota,
an 'in use' badge and Switch to (PUT /api/me/pool-accounts), plus an
Automatic row; an admin's pin locks them. A saved profile with the same
email as a pool account is shown once, as the pool row. Developers now see
Profiles too, with only their pool rows; Cursor gets a tab when the pool
holds a Cursor account for the user.
This commit is contained in:
2026-11-06 09:21:46 +07:00
parent 890cd08f57
commit 087c1ddb5d
13 changed files with 418 additions and 324 deletions
@@ -1,120 +0,0 @@
/**
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
* mode, every user picks which account of the shared account pool their own
* new sessions run on, per tool, among the accounts their pool group may use
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session. Renders nothing in local mode
* or when the pool holds no account the user may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsRow, SettingsSection } from "./SettingsRow";
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
export function PoolAccountSettings() {
const { t } = useTranslation("settings");
const { local } = usePermissions();
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
const [busy, setBusy] = useState<PoolTool | null>(null);
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setTools(r.tools))
.catch(() => setTools(null));
}, [local]);
if (local || !tools?.length) return null;
const choose = async (tool: PoolTool, account: string | null) => {
setBusy(tool);
try {
const r = await api.me.choosePoolAccount(tool, account);
setTools((list) =>
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
);
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
return (
<SettingsSection
id="pool-account"
title={t("poolAccount.title")}
description={t("poolAccount.description")}
>
{tools.map((x) => (
<SettingsRow
key={x.tool}
id={`pool-account-${x.tool}`}
label={
<span className="inline-flex items-center gap-1.5">
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
{LABEL[x.tool]}
</span>
}
description={
x.pin ? (
<span className="inline-flex items-center gap-1">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: x.pin })}
</span>
) : (
t("poolAccount.count", { count: x.accounts.length })
)
}
disabled={!!x.pin}
control={
<select
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
value={x.pin ?? x.choice ?? ""}
disabled={!!x.pin || busy === x.tool}
onChange={(e) => void choose(x.tool, e.target.value || null)}
className="select !py-1 !text-xs max-w-[16rem]"
>
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
{x.accounts.map((a) => (
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
{[
a.name,
a.email && a.email !== a.name ? `(${a.email})` : null,
a.expired
? `— ${t("poolAccount.expired")}`
: a.full
? `— ${t("poolAccount.full")}`
: a.quotaPercent != null
? `— ${Math.round(a.quotaPercent)}%`
: null,
]
.filter(Boolean)
.join(" ")}
</option>
))}
</select>
}
/>
))}
</SettingsSection>
);
}
@@ -0,0 +1,140 @@
/**
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
* AI tools → Profiles (server mode). Each account this user's pool group may
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
* to" picks which account the user's OWN new sessions run on
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* Automatic leaves it to the balancer; an admin's pin locks every row. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
const btnCls =
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
return new Set(
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
);
}
export function PoolProfiles({
entry,
onChange,
}: {
entry: MyPoolTool;
onChange: (next: MyPoolTool) => void;
}) {
const { t } = useTranslation("settings");
const { t: tc } = useTranslation("ccConfig");
const [busy, setBusy] = useState<string | null>(null);
const locked = !!entry.pin;
/** The account the user's new sessions run on now; null = Automatic. */
const current = entry.pin ?? entry.choice;
const choose = async (account: string | null) => {
setBusy(account ?? "");
try {
const r = await api.me.choosePoolAccount(entry.tool, account);
onChange({ ...entry, choice: r.choice });
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
const inUse = (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("poolAccount.inUse")}
</span>
);
const poolBadge = (
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
{t("poolAccount.badge")}
</span>
);
const switchBtn = (account: string | null, disabled = false) => (
<button
type="button"
disabled={busy !== null || disabled}
onClick={() => void choose(account)}
className={btnCls}
>
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
</button>
);
return (
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
{locked && (
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: entry.pin })}
</p>
)}
{!locked && (
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-fg-primary">
{t("poolAccount.automatic")}
</span>
{poolBadge}
{current === null && inUse}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{t("poolAccount.automaticHint")}
</span>
{current !== null && switchBtn(null)}
</div>
</div>
)}
{entry.accounts.map((a) => {
const status = a.expired
? t("poolAccount.expired")
: a.full
? t("poolAccount.full")
: a.quotaPercent != null
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
: null;
return (
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
{poolBadge}
{current === a.name && inUse}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{a.email && a.email !== a.name ? a.email : ""}
</span>
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
</div>
{status && (
<div
className={`text-[10px] mt-0.5 ${
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
}`}
>
{status}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -4,14 +4,20 @@
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
* warning — this panel used to be a modal on the Claude Code page); Codex and
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
* each Claude/Codex profile to the account pool in Administration → Users,
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
* pool's Add dialog on it), and shows how many accounts the pool holds.
* (Antigravity: an OS-keyring login) says why. In server mode the shared
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
* rows of the same list (PoolProfiles: "Switch to" picks the account the
* user's own new sessions run on, plus an Automatic row) — there is no
* separate Pool account section any more. A saved profile holding the same
* account (email) as a pool row is folded into that row. Staff also see the
* host's saved logins and how many accounts the whole pool holds; an admin can
* move a profile into the pool in Administration → Users
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
* developer sees only the pool rows of the tools they may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
@@ -33,6 +39,7 @@ import {
type CcProfile,
type CcProfileUsage,
type CcUsageWindow,
type MyPoolTool,
type ToolProfile,
type ToolProfileIdentity,
type ToolProfilesTool,
@@ -53,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import { SkeletonRows } from "../config/configPrimitives";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSection } from "./SettingsRow";
import { PoolProfiles, poolEmails } from "./PoolProfiles";
/** The tools the panel covers, in tab order, with the command that signs one in. */
const TOOLS: { id: string; label: string; login: string }[] = [
/** The tools the panel covers, in tab order, with the command that signs one in.
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
{ id: "claude", label: "Claude Code", login: "claude auth login" },
{ id: "codex", label: "Codex", login: "codex login" },
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
{ id: "antigravity", label: "Antigravity", login: "agy" },
];
@@ -72,15 +82,21 @@ export function ToolProfilesSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const { local, seesAll } = usePermissions();
const [tab, setTab] = useState<string>("claude");
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(null);
/** Server mode: the pool accounts THIS user may use, per tool. */
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
const shown = TOOLS.filter((x) => {
if (mineOf(x.id)) return true;
// The host's own logins are a staff-only read (a developer would get 403s).
if (!seesAll || x.poolOnly) return false;
const tool = aiTools.byId(x.id);
if (x.id === "claude") return tool?.enabled !== false;
return !!tool && tool.installed && tool.enabled !== false;
});
const [tab, setTab] = useState<string>("claude");
const current = shown.find((x) => x.id === tab) ?? shown[0];
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(null);
const loadOthers = useCallback(
() =>
@@ -91,9 +107,9 @@ export function ToolProfilesSettings() {
[]
);
useEffect(() => {
void loadOthers();
}, [loadOthers]);
// The pool is a server-mode, staff-only read.
if (seesAll) void loadOthers();
}, [loadOthers, seesAll]);
// The whole pool is a server-mode, staff-only read.
useEffect(() => {
if (local || !seesAll) return;
api.accountPool
@@ -101,10 +117,23 @@ export function ToolProfilesSettings() {
.then(setPool)
.catch(() => setPool(null));
}, [local, seesAll]);
// The accounts this user may run on: server mode, every role.
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setMine(r.tools))
.catch(() => setMine([]));
}, [local]);
if (!current) return null;
const poolCount = (tool: string) =>
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
const entry = mineOf(current.id);
const setEntry = (next: MyPoolTool) =>
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
const merged = poolEmails(entry);
return (
<SettingsSection
@@ -132,11 +161,18 @@ export function ToolProfilesSettings() {
))}
</div>
<div role="tabpanel" className="mt-3 space-y-3">
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
<PoolLine count={poolCount(current.id)!} />
)}
{current.id === "claude" ? (
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
{!seesAll || current.poolOnly ? (
poolRows
) : current.id === "claude" ? (
<ClaudeProfiles
pooled={!local && POOL_TOOLS.has("claude")}
local={local}
merged={merged}
poolRows={poolRows}
/>
) : (
<OtherProfiles
key={current.id}
@@ -146,6 +182,8 @@ export function ToolProfilesSettings() {
state={others?.find((o) => o.tool === current.id) ?? null}
loading={others === null}
pooled={!local && POOL_TOOLS.has(current.id)}
merged={merged}
poolRows={poolRows}
onChanged={loadOthers}
/>
)}
@@ -295,6 +333,8 @@ function OtherProfiles({
state,
loading,
pooled,
merged,
poolRows,
onChanged,
}: {
tool: string;
@@ -303,6 +343,10 @@ function OtherProfiles({
state: ToolProfilesTool | null;
loading: boolean;
pooled: boolean;
/** Emails of the pool rows: a saved login of one is shown as that row. */
merged: Set<string>;
/** The pool accounts this user may use, as rows of this list. */
poolRows: ReactNode;
onChanged: () => Promise<unknown>;
}) {
const { t } = useTranslation("settings");
@@ -320,8 +364,8 @@ function OtherProfiles({
</p>
);
}
const items = state.items;
const activeSaved = items.some((p) => p.active);
const activeSaved = state.items.some((p) => p.active);
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
const remove = async (p: ToolProfile) => {
const ok = await confirmDialog({
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
@@ -376,7 +420,7 @@ function OtherProfiles({
</div>
)}
</div>
{items.length === 0 && (
{items.length === 0 && !poolRows && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{tc("profiles.empty")}
</div>
@@ -445,6 +489,7 @@ function OtherProfiles({
)}
</div>
))}
{poolRows}
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
</div>
);
@@ -482,7 +527,19 @@ function ActiveBadge() {
// save button only appears while the live account is unsaved, because one
// account is one profile.
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
function ClaudeProfiles({
pooled,
local,
merged,
poolRows,
}: {
pooled: boolean;
local: boolean;
/** Emails of the pool rows: a saved profile of one is shown as that row. */
merged: Set<string>;
/** The pool accounts this user may use, as rows of this list. */
poolRows: ReactNode;
}) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
@@ -538,6 +595,12 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
)
: null;
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
/** Listed as its own row: not hidden by this browser, and not a pool account
* (that one is the pool's row — one account, one row). */
const listed = useCallback(
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
[reveal, isHidden, merged]
);
const load = useCallback(async () => {
try {
@@ -586,11 +649,11 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
if (!reveal && isHidden(p)) continue;
if (!listed(p)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage, isHidden, reveal]);
}, [items, checkUsage, listed]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -691,75 +754,73 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
{items !== null && items.filter(listed).length === 0 && !poolRows && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items
?.filter((p) => reveal || !isHidden(p))
.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
{items?.filter(listed).map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
{poolRows}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
@@ -1,85 +0,0 @@
/**
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
* hidden in local mode and with no usable account; one select per tool that
* starts on the user's choice (or Automatic) and saves a pick through
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../../i18n";
import { PoolAccountSettings } from "../PoolAccountSettings";
const perms = vi.hoisted(() => ({ local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
}));
const account = (name: string) => ({
name,
kind: "login",
email: `${name}@x.io`,
expired: false,
quotaPercent: 20,
full: false,
});
async function show() {
render(<PoolAccountSettings />);
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
});
}
beforeEach(() => {
perms.local = false;
m.poolAccounts.mockReset();
m.choosePoolAccount.mockReset();
});
describe("PoolAccountSettings", () => {
it("renders nothing in local mode or with no account to choose", async () => {
perms.local = true;
await show();
expect(m.poolAccounts).not.toHaveBeenCalled();
expect(screen.queryByRole("combobox")).toBeNull();
perms.local = false;
m.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("starts on the user's choice and saves a new pick", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
],
});
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("two");
await userEvent.selectOptions(select, "one");
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
await userEvent.selectOptions(select, "");
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
});
it("is locked on an admin's pin", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
],
});
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.disabled).toBe(true);
expect(select.value).toBe("one");
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
});
});
@@ -4,12 +4,16 @@
* failing account costs the others nothing), and its right-click delete asks
* first and never offers to delete the only copy of the live account; Codex's
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
* switched; and in server mode a profile links into the account pool.
* switched; and in server mode a profile links into the account pool, while
* the pool accounts the user may use are rows of the same list — "Switch to"
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
* the balancer, an admin's pin locks them, a saved profile of the same email
* is folded into the pool row, and a developer sees only the pool rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
},
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
pool: { list: vi.fn() },
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
};
});
vi.mock("../../../lib/api", () => ({
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
ccConfig: m.cc,
toolProfiles: m.tp,
accountPool: m.pool,
me: m.me,
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
},
}));
@@ -70,6 +76,7 @@ beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
perms.local = true;
perms.seesAll = true;
m.cc.profiles.mockResolvedValue({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
@@ -106,6 +113,7 @@ beforeEach(() => {
});
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
m.me.poolAccounts.mockResolvedValue({ tools: [] });
});
describe("ToolProfilesSettings — Claude", () => {
@@ -218,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
});
});
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
name,
kind: "login",
email,
expired: false,
quotaPercent: 20,
full: false,
});
describe("ToolProfilesSettings — pool accounts as profiles", () => {
beforeEach(() => {
perms.local = false;
m.me.poolAccounts.mockResolvedValue({
tools: [
{
tool: "claude",
pin: null,
choice: "two",
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
},
],
});
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
tool,
choice: account,
switched: account ? "switched" : null,
}));
});
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
await show();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
});
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
const user = userEvent.setup();
await show();
const one = screen.getByTestId("pool-row-claude-one");
await user.click(within(one).getByRole("button", { name: "Switch to" }));
await settle();
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
expect(m.cc.activateProfile).not.toHaveBeenCalled();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
const auto = screen.getByTestId("pool-row-claude-automatic");
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
await settle();
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
});
it("locks every pool row on an admin's pin", async () => {
m.me.poolAccounts.mockResolvedValue({
tools: [
{
tool: "claude",
pin: "one",
choice: null,
accounts: [poolAccount("one"), poolAccount("two")],
},
],
});
await show();
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
expect(
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
).toBeNull();
});
it("shows a developer only the pool rows, never the host's logins", async () => {
perms.seesAll = false;
await show();
expect(m.cc.profiles).not.toHaveBeenCalled();
expect(m.tp.list).not.toHaveBeenCalled();
expect(m.pool.list).not.toHaveBeenCalled();
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
});
it("is not shown to a developer with no pool account to use", async () => {
perms.seesAll = false;
m.me.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("tablist")).toBeNull();
});
});
+5 -6
View File
@@ -1438,18 +1438,17 @@
}
},
"poolAccount": {
"title": "Pool account",
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
"automatic": "Automatic",
"select": "{{tool}} pool account",
"count_one": "{{count}} account you may use",
"count_other": "{{count}} accounts you may use",
"pinned": "An admin pinned you to {{account}}",
"expired": "expired",
"full": "quota full",
"automaticSaved": "Back to automatic — AgentHub picks the account",
"switchedNow": "Switched to {{account}} — running sessions follow it",
"nextSession": "{{account}} is used from your next session"
"nextSession": "{{account}} is used from your next session",
"inUse": "in use",
"badge": "Pool",
"automaticHint": "Your new sessions run on the least-used pool account",
"quota": "Quota {{pct}}%"
},
"companyHub": {
"title": "Company hub",
+5 -6
View File
@@ -1426,18 +1426,17 @@
}
},
"poolAccount": {
"title": "Tài khoản pool",
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
"automatic": "Tự động",
"select": "Tài khoản pool của {{tool}}",
"count_one": "{{count}} tài khoản bạn được dùng",
"count_other": "{{count}} tài khoản bạn được dùng",
"pinned": "Admin đã ghim bạn vào {{account}}",
"expired": "hết hạn",
"full": "hết quota",
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
"nextSession": "{{account}} được dùng từ phiên tiếp theo",
"inUse": "đang dùng",
"badge": "Pool",
"automaticHint": "Phiên mới của bạn chạy trên tài khoản pool ít dùng nhất",
"quota": "Quota {{pct}}%"
},
"companyHub": {
"title": "Hub công ty",
+2 -6
View File
@@ -324,17 +324,13 @@ export const SETTINGS_INDEX: {
"codex",
"omp",
"pool",
"pool account",
"automatic",
"hồ sơ",
"tài khoản",
],
to: "#tool-profiles",
},
{
section: "agents",
titleKey: "poolAccount.title",
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
to: "#pool-account",
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
+5 -7
View File
@@ -74,7 +74,6 @@ import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsS
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
@@ -255,7 +254,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
};
}, [location.key, location.hash]);
const { isAdmin, local, seesAll } = usePermissions();
const { isAdmin, local } = usePermissions();
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
@@ -310,11 +309,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* Every AI tool's login profiles, linked to the account pool: the
host's logins, a staff-only read (a developer would get 403s). */}
{seesAll && <ToolProfilesSettings />}
{/* Every user's own pick of a pool account (server mode; hides itself). */}
<PoolAccountSettings />
{/* Every AI tool's login profiles with the pool accounts the user may
run on as rows of the same list: staff see the host's logins too,
a developer only the pool rows (it hides itself with none). */}
<ToolProfilesSettings />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */}
+9 -4
View File
@@ -692,15 +692,20 @@ tools**. An account's Edit (pencil) uses the same role / No pool list.
### A user's own pool account
When the pool holds more than one account a user may use, they pick which one
their own new sessions run on: **Settings → AI tools → Pool account**, one
select per tool (Claude, Codex, Cursor), every role, server mode only
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
their own new sessions run on in **Settings → AI tools → Profiles**: each pool
account they may use is a row of that tool's tab (Claude, Codex, Cursor) with
its quota and a **Switch to**, beside an **Automatic** row — there is no
separate Pool account section. Every role, server mode only; a developer's
Profiles shows only these pool rows, staff see the host's saved logins too, and
a saved profile with the same email as a pool account is shown once, as the
pool row (`GET/PUT /api/me/pool-accounts`, `account_choices`,
`server/lib/account-pins.js`).
- The list is only what their group allows (or the shared pool); a choice
outside it is refused (`ENOACCOUNT`), and one later taken from the group
falls back to the balancer.
- **Automatic** (the default) leaves it to the balancer.
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
- An admin's pin wins and locks the rows (`ELOCKED`); the choice comes back
once the pin is removed.
- A Claude login goes into the user's home at once, like an admin's switch,
so running panes follow it; Codex and Cursor take it at the next session (a
+1 -1
View File
@@ -2089,7 +2089,7 @@ try {
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_id)`);
// A user's OWN choice of one pool account per tool (Settings → AI tools →
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
// Profiles, account-pins.js `setChoice`): below an admin's pin, above the
// balancer, and only among the accounts the user's group may use.
db.exec(`
CREATE TABLE IF NOT EXISTS account_choices (
+2 -2
View File
@@ -14,7 +14,7 @@
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
*
* A user may also CHOOSE an account of their own (`account_choices`, Settings
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
* → AI tools → Profiles, `/api/me/pool-accounts`): `preferredFor` — the
* balancers' question — answers the admin's pin first, then that choice. A
* choice is only ever among the accounts the user's group may use
* (pool-groups.js `allowed`), checked when it is made and again at every
@@ -109,7 +109,7 @@ function setChoice(userId, tool, account) {
}
/**
* What Settings → AI tools → Pool account shows `userId`: per tool with at
* What Settings → AI tools → Profiles shows `userId`: per tool with at
* least one account they may use, those accounts (never a token), the admin's
* pin and their own choice.
*/
+1 -1
View File
@@ -319,7 +319,7 @@ async function poolFor(uid) {
const wholeSession = tool !== "claude";
out[tool] = {
pin,
// The user's own pick (Settings → AI tools → Pool account), below a pin.
// The user's own pick (Settings → AI tools → Profiles), below a pin.
choice,
group: group ? { id: group.id, name: group.name } : null,
accounts: status