From 087c1ddb5d009fb354d9040bb74ee362f9a3e26f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 09:21:46 +0700 Subject: [PATCH] feat(settings): pool accounts are rows of Profiles; the separate Pool account section is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../settings/PoolAccountSettings.tsx | 120 --------- .../src/components/settings/PoolProfiles.tsx | 140 +++++++++++ .../settings/ToolProfilesSettings.tsx | 229 +++++++++++------- .../__tests__/PoolAccountSettings.test.tsx | 85 ------- .../__tests__/ToolProfilesSettings.test.tsx | 105 +++++++- client/src/i18n/locales/en/settings.json | 11 +- client/src/i18n/locales/vi/settings.json | 11 +- client/src/lib/settingsSearch.ts | 8 +- client/src/pages/SettingsHub.tsx | 12 +- docs/SERVER-MODE.md | 13 +- server/db.js | 2 +- server/lib/account-pins.js | 4 +- server/routes/users.js | 2 +- 13 files changed, 418 insertions(+), 324 deletions(-) delete mode 100644 client/src/components/settings/PoolAccountSettings.tsx create mode 100644 client/src/components/settings/PoolProfiles.tsx delete mode 100644 client/src/components/settings/__tests__/PoolAccountSettings.test.tsx diff --git a/client/src/components/settings/PoolAccountSettings.tsx b/client/src/components/settings/PoolAccountSettings.tsx deleted file mode 100644 index 7794af07..00000000 --- a/client/src/components/settings/PoolAccountSettings.tsx +++ /dev/null @@ -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ĩ - */ - -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 = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" }; - -export function PoolAccountSettings() { - const { t } = useTranslation("settings"); - const { local } = usePermissions(); - const [tools, setTools] = useState(null); - const [busy, setBusy] = useState(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 ( - - {tools.map((x) => ( - - - {LABEL[x.tool]} - - } - description={ - x.pin ? ( - - - {t("poolAccount.pinned", { account: x.pin })} - - ) : ( - t("poolAccount.count", { count: x.accounts.length }) - ) - } - disabled={!!x.pin} - control={ - - } - /> - ))} - - ); -} diff --git a/client/src/components/settings/PoolProfiles.tsx b/client/src/components/settings/PoolProfiles.tsx new file mode 100644 index 00000000..0e584e00 --- /dev/null +++ b/client/src/components/settings/PoolProfiles.tsx @@ -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ĩ + */ + +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 { + 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(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 = ( + + {t("poolAccount.inUse")} + + ); + const poolBadge = ( + + {t("poolAccount.badge")} + + ); + const switchBtn = (account: string | null, disabled = false) => ( + + ); + + return ( +
+ {locked && ( +

+ + {t("poolAccount.pinned", { account: entry.pin })} +

+ )} + {!locked && ( +
+
+ + {t("poolAccount.automatic")} + + {poolBadge} + {current === null && inUse} + + {t("poolAccount.automaticHint")} + + {current !== null && switchBtn(null)} +
+
+ )} + {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 ( +
+
+ {a.name} + {poolBadge} + {current === a.name && inUse} + + {a.email && a.email !== a.name ? a.email : ""} + + {!locked && current !== a.name && switchBtn(a.name, a.expired)} +
+ {status && ( +
+ {status} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/client/src/components/settings/ToolProfilesSettings.tsx b/client/src/components/settings/ToolProfilesSettings.tsx index b89f6d10..5c0e8d86 100644 --- a/client/src/components/settings/ToolProfilesSettings.tsx +++ b/client/src/components/settings/ToolProfilesSettings.tsx @@ -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=&poolProfile=` 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=&poolProfile=` 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ĩ */ -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("claude"); + const [others, setOthers] = useState(null); + const [pool, setPool] = useState(null); + /** Server mode: the pool accounts THIS user may use, per tool. */ + const [mine, setMine] = useState(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("claude"); const current = shown.find((x) => x.id === tab) ?? shown[0]; - const [others, setOthers] = useState(null); - const [pool, setPool] = useState(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 ? : null; + const merged = poolEmails(entry); return (
- {POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && ( + {seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && ( )} - {current.id === "claude" ? ( - + {!seesAll || current.poolOnly ? ( + poolRows + ) : current.id === "claude" ? ( + ) : ( 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; + /** The pool accounts this user may use, as rows of this list. */ + poolRows: ReactNode; onChanged: () => Promise; }) { const { t } = useTranslation("settings"); @@ -320,8 +364,8 @@ function OtherProfiles({

); } - 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({
)} - {items.length === 0 && ( + {items.length === 0 && !poolRows && (
{tc("profiles.empty")}
@@ -445,6 +489,7 @@ function OtherProfiles({ )} ))} + {poolRows} ); @@ -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; + /** The pool accounts this user may use, as rows of this list. */ + poolRows: ReactNode; +}) { const { t } = useTranslation("ccConfig"); const [items, setItems] = useState(null); const [active, setActive] = useState(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 }) {items === null && !error && } - {items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && ( + {items !== null && items.filter(listed).length === 0 && !poolRows && (
{t("profiles.empty")}
)} - {items - ?.filter((p) => reveal || !isHidden(p)) - .map((p) => ( -
ctx.open(e, p)} - onClickCapture={(e) => toggleRow(e, p)} - className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`} - > -
- 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) && } - - {p.email || p.displayName || p.accountUuid} - - {!shownActive(p) && ( - - )} - {pooled && } - {deletable(p) && ( - - )} -
- - void checkUsage(p.name)} + {items?.filter(listed).map((p) => ( +
ctx.open(e, p)} + onClickCapture={(e) => toggleRow(e, p)} + className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`} + > +
+ 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 && ( -
- {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} -
+ {shownActive(p) && } + + {p.email || p.displayName || p.accountUuid} + + {!shownActive(p) && ( + + )} + {pooled && } + {deletable(p) && ( + )}
- ))} + + void checkUsage(p.name)} + /> + {p.savedAt && ( +
+ {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} +
+ )} +
+ ))} + + {poolRows} {ctx.menu && ( diff --git a/client/src/components/settings/__tests__/PoolAccountSettings.test.tsx b/client/src/components/settings/__tests__/PoolAccountSettings.test.tsx deleted file mode 100644 index 2ffd2946..00000000 --- a/client/src/components/settings/__tests__/PoolAccountSettings.test.tsx +++ /dev/null @@ -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ĩ - */ - -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(); - 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(); - }); -}); diff --git a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx index e4a12d3e..ac925ed8 100644 --- a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx +++ b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx @@ -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ĩ */ 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(); + }); +}); diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 761ba369..2b69bfe1 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -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", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 625aeed2..836c4a17 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -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", diff --git a/client/src/lib/settingsSearch.ts b/client/src/lib/settingsSearch.ts index 24be7165..5e9c4a65 100644 --- a/client/src/lib/settingsSearch.ts +++ b/client/src/lib/settingsSearch.ts @@ -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" }, diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index 7e0688d2..b684cbb6 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -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 } = {}) { - {/* 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 && } - {/* Every user's own pick of a pool account (server mode; hides itself). */} - + {/* 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). */} + {/* The Hub's install switch: server mode, an admin's (it hides itself). */} {/* The host's omp login for members: server mode + S3, an admin's. */} diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 970116d1..c96729cf 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -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 diff --git a/server/db.js b/server/db.js index 6eceabdc..56f16d4a 100644 --- a/server/db.js +++ b/server/db.js @@ -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 ( diff --git a/server/lib/account-pins.js b/server/lib/account-pins.js index 6e23f7bf..f3f63c16 100644 --- a/server/lib/account-pins.js +++ b/server/lib/account-pins.js @@ -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. */ diff --git a/server/routes/users.js b/server/routes/users.js index 855c36e6..ce7f6e40 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -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