From c042adc51b777e748acf3302172012adf8e4f4e7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 19 Oct 2026 10:45:54 +0700 Subject: [PATCH] ui(pool): pick an account's users from its own Edit Each account row in the Account pool card has a pencil: tick the users whose sessions run on it (one moves off any other account, unticking sends one back to the balancer). PUT /api/accounts/:name/users saves the set in one transaction, audits each change and switches a Claude login into each added user's home at once. The card-level switch button and pinned list are gone. --- .../src/components/users/AccountPoolCard.tsx | 227 +++++++----------- .../users/__tests__/AccountPoolCard.test.tsx | 67 +++--- client/src/i18n/locales/en/users.json | 24 +- client/src/i18n/locales/vi/users.json | 24 +- client/src/lib/api.ts | 18 +- docs/API.md | 2 +- docs/SERVER-MODE.md | 8 +- .../server-mode-account-pool.test.js | 36 +++ server/lib/account-pins.js | 39 ++- server/openapi-extra/auth.js | 36 +++ server/routes/accounts.js | 56 ++++- 11 files changed, 305 insertions(+), 232 deletions(-) diff --git a/client/src/components/users/AccountPoolCard.tsx b/client/src/components/users/AccountPoolCard.tsx index 6a23307b..7c1cfc47 100644 --- a/client/src/components/users/AccountPoolCard.tsx +++ b/client/src/components/users/AccountPoolCard.tsx @@ -8,16 +8,16 @@ * `.credentials.json`, or a `claude setup-token` token; for 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 admin can also switch a user to one account - * (`PUT /api/accounts/pins`): that user's sessions skip the balancer, and a - * Claude login goes into their home at once — no restart. + * 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. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { KeyRound, Pin, PinOff, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { api, type AccountPin, @@ -32,6 +32,7 @@ import { toast } from "../../lib/toast"; import { isSubmitEnter } from "../../lib/keys"; import { useSpin } from "../../hooks/useSpin"; import { ConfirmModal } from "../ConfirmModal"; +import { Checkbox } from "../Checkbox"; type Row = { tool: PoolTool; @@ -78,8 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const [removing, setRemoving] = useState(null); - /** The pin dialog, opened empty or on one existing pin (to change or unpin it). */ - const [pinning, setPinning] = useState<{ tool: PoolTool; userId?: number } | null>(null); + const [editing, setEditing] = useState(null); const load = useCallback( (force = false) => @@ -99,11 +99,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { }, [load]); const rows = pool ? rowsOf(pool) : []; - const pins = pool - ? (["claude", "codex"] as const).flatMap((tool) => - pinsOf(pool, tool).map((p) => ({ ...p, tool })) - ) - : []; const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; return ( @@ -125,15 +120,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { > - {isAdmin && rows.length > 0 && ( - - )} {isAdmin && ( - )} - - ))} - - - )} - {adding && ( setAdding(false)} @@ -287,21 +254,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { }} /> )} - {pinning && pool && ( - setPinning(null)} - onDone={(user, account, switched) => { - const key = !account - ? "unpinned" - : switched === "switched" || switched === "own-login" || switched === "expired" - ? switched - : "pinned"; - (key === "own-login" || key === "expired" ? toast.info : toast.success)( - t(`pool.pinDone.${key}`, { user, account }) + row={editing} + onClose={() => setEditing(null)} + onDone={(r) => { + const left = r.added.filter( + (u) => u.switched === "own-login" || u.switched === "expired" ); - setPinning(null); + toast.success(t("pool.editDone", { name: editing.name })); + for (const u of left) { + toast.info( + t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name }) + ); + } + setEditing(null); void load(); }} /> @@ -311,25 +279,26 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { } /** - * Switch a user to one account of a tool, or back to automatic. Opened on an - * existing pin it starts on that user with "Automatic", so one click unpins. + * 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 + * them back to the least-used account. Saved in one request, password again. */ -function PinUserDialog({ +function EditAccountDialog({ pool, - initial, + row, onClose, onDone, }: { pool: AccountPool; - initial: { tool: PoolTool; userId?: number }; + row: Row; onClose: () => void; - onDone: (user: string, account: string | null, switched: string | null) => void; + onDone: (r: Awaited>) => void; }) { const { t } = useTranslation("users"); - const [tool, setTool] = useState(initial.tool); + const pins = pinsOf(pool, row.tool); + const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId); const [users, setUsers] = useState(null); - const [userId, setUserId] = useState(initial.userId ?? null); - const [account, setAccount] = useState(""); + const [chosen, setChosen] = useState>(() => new Set(initial)); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -341,18 +310,22 @@ function PinUserDialog({ .catch(() => setUsers([])); }, []); - const accounts = tool === "codex" ? pool.codex : pool.accounts; - const pinOf = (id: number | null, of: PoolTool) => - pinsOf(pool, of).find((p) => p.userId === id)?.account ?? ""; - const ready = userId != null && current.length > 0 && account !== pinOf(userId, tool); + const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id)); + const ready = changed && current.length > 0; + const toggle = (id: number, on: boolean) => + setChosen((prev) => { + const next = new Set(prev); + if (on) next.add(id); + else next.delete(id); + return next; + }); const submit = async () => { - if (!ready || busy || userId == null) return; + if (!ready || busy) return; setBusy(true); setError(null); try { - const r = await api.accountPool.pin(tool, userId, account || null, current); - onDone(r.pin.username, r.pin.account, r.switched ?? null); + onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current)); } catch (e) { setError(errorText(t, e)); } finally { @@ -363,9 +336,9 @@ function PinUserDialog({ return ( { - if (isSubmitEnter(e)) { + if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) { e.preventDefault(); void submit(); } }} > - - - - - - - - - +
+ {t("pool.users")} +
+ {users === null && ( +

{t("pool.loadingUsers")}

+ )} + {users?.length === 0 && ( +

{t("pool.noUsers")}

+ )} + {users?.map((u) => { + const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name); + return ( +
+ toggle(u.id, on)} + label={ + + {u.username} + {u.disabled && ( + ({t("disabled")}) + )} + {elsewhere && ( + + {t("pool.onAccount", { account: elsewhere.account })} + + )} + + } + labelClassName="text-sm text-fg-primary" + /> +
+ ); + })} +
+
*/ @@ -20,7 +20,7 @@ const accountPool = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), profiles: vi.fn(), - pin: vi.fn(), + setUsers: vi.fn(), })); const users = vi.hoisted(() => ({ list: vi.fn() })); vi.mock("../../../lib/api", async (importOriginal) => { @@ -168,53 +168,42 @@ describe("AccountPoolCard", () => { ); }); - it("pins a user to an account and unpins them, with the admin's own password", async () => { + it("sets who runs on an account from its Edit, with the admin's own password", async () => { accountPool.list.mockResolvedValue({ ...POOL, - pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, + pins: { + claude: [ + { userId: 8, username: "leo", account: "team-1" }, + { userId: 7, username: "mia", account: "team-0" }, + ], + codex: [], + }, }); - accountPool.pin.mockResolvedValue({ - pin: { userId: 7, username: "mia", account: "team-1" }, - switched: "switched", + accountPool.setUsers.mockResolvedValue({ + added: [{ userId: 7, username: "mia", switched: "switched" }], + removed: [{ userId: 8, username: "leo" }], }); const user = userEvent.setup(); render(); expect(await screen.findByText("Used by: leo")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /switch a user's account/i })); + expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Edit team-1" })); const dialog = await screen.findByRole("dialog"); - await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument()); - await user.selectOptions(screen.getByLabelText("User"), "7"); - await user.selectOptions(screen.getByLabelText("Account"), "team-1"); - const confirm = Array.from(dialog.querySelectorAll("button")).find( - (b) => b.textContent === "Switch" + const leo = await screen.findByRole("checkbox", { name: /leo/ }); + const mia = screen.getByRole("checkbox", { name: /mia/ }); + expect(leo).toHaveAttribute("aria-checked", "true"); + expect(mia).toHaveAttribute("aria-checked", "false"); + expect(screen.getByText("now on team-0")).toBeInTheDocument(); + await user.click(mia); + await user.click(leo); + const save = Array.from(dialog.querySelectorAll("button")).find( + (b) => b.textContent === "Save" )!; - expect(confirm).toBeDisabled(); + expect(save).toBeDisabled(); await user.type(screen.getByLabelText("Your password"), "boss-pass"); - await user.click(confirm); + await user.click(save); await waitFor(() => - expect(accountPool.pin).toHaveBeenCalledWith("claude", 7, "team-1", "boss-pass") - ); - }); - - it("unpins from the pinned list", async () => { - accountPool.list.mockResolvedValue({ - ...POOL, - pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, - }); - accountPool.pin.mockResolvedValue({ - pin: { userId: 8, username: "leo", account: null }, - switched: null, - }); - const user = userEvent.setup(); - render(); - await user.click(await screen.findByRole("button", { name: "Back to automatic for leo" })); - const dialog = await screen.findByRole("dialog"); - await user.type(screen.getByLabelText("Your password"), "boss-pass"); - await user.click( - Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Switch")! - ); - await waitFor(() => - expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass") + expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass") ); }); }); diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 34dec552..3937d624 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -199,28 +199,18 @@ }, "refreshFailed": "refresh failed", "pinned": "Used by: {{users}}", - "pinUser": "Switch a user's account", - "pinTitle": "Switch a user's account", - "pinHint": "That user's sessions run on the chosen account instead of the least-used one — even when it is full. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions.", - "pinButton": "Switch", - "user": "User", - "chooseUser": "Choose a user…", "loadingUsers": "Loading…", - "pinAccount": "Account", - "noPin": "Automatic — the least-used account", - "pinsTitle": "Switched users", - "unpin": "Back to automatic for {{name}}", "pinDone": { - "pinned": "{{user}} now runs on {{account}}", - "unpinned": "{{user}} is back on the least-used account", - "switched": "{{user}} switched to {{account}} — running sessions follow at their next login check", "own-login": "{{user}} signed in with their own login, which was left alone", "expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account" }, - "toolName": { - "claude": "Claude", - "codex": "Codex" - } + "edit": "Edit {{name}}", + "editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.", + "save": "Save", + "users": "Users on this account", + "noUsers": "No users yet.", + "onAccount": "now on {{account}}", + "editDone": "{{name}} saved" }, "delete": { "button": "Delete user", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index d6c55031..9aecb640 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -199,28 +199,18 @@ }, "refreshFailed": "làm mới lỗi", "pinned": "Đang dùng: {{users}}", - "pinUser": "Đổi tài khoản cho user", - "pinTitle": "Đổi tài khoản cho user", - "pinHint": "Phiên của user này chạy trên tài khoản đã chọn thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới.", - "pinButton": "Đổi", - "user": "User", - "chooseUser": "Chọn user…", "loadingUsers": "Đang tải…", - "pinAccount": "Tài khoản", - "noPin": "Tự động — tài khoản ít tải nhất", - "pinsTitle": "User đã đổi tài khoản", - "unpin": "Trả {{name}} về tự động", "pinDone": { - "pinned": "{{user}} giờ chạy trên {{account}}", - "unpinned": "{{user}} quay lại dùng tài khoản ít tải nhất", - "switched": "Đã đổi {{user}} sang {{account}} — phiên đang chạy sẽ theo ở lần kiểm tra đăng nhập kế tiếp", "own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè", "expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất" }, - "toolName": { - "claude": "Claude", - "codex": "Codex" - } + "edit": "Sửa {{name}}", + "editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.", + "save": "Lưu", + "users": "User dùng tài khoản này", + "noUsers": "Chưa có user nào.", + "onAccount": "đang ở {{account}}", + "editDone": "Đã lưu {{name}}" }, "delete": { "button": "Xoá user", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 411d3e78..d8ed903e 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1061,15 +1061,19 @@ export const api = { method: "POST", body: JSON.stringify({ ...body, currentPassword }), }), - /** PUT /api/accounts/pins — admin: switch a user to one account (`null` = automatic), password again. */ - pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) => + /** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */ + setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) => request<{ - pin: { userId: number; username: string; account: string | null }; - /** What happened now: a Claude login goes into the user's home at once. */ - switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null; - }>("/accounts/pins", { + /** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */ + added: { + userId: number; + username: string; + switched: "switched" | "kept" | "own-login" | "expired" | "next-session"; + }[]; + removed: { userId: number; username: string }[]; + }>(`/accounts/${encodeURIComponent(name)}/users`, { method: "PUT", - body: JSON.stringify({ tool, userId, account, currentPassword }), + body: JSON.stringify({ tool, userIds, currentPassword }), }), /** DELETE /api/accounts/:name — admin, with their OWN password again. */ remove: (tool: PoolTool, name: string, currentPassword: string) => diff --git a/docs/API.md b/docs/API.md index 4edf4a1b..3aac5fb3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -91,7 +91,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}` `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). -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`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). 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`). 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`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). 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. **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`): diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 121a5ce6..fcf2e92f 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -425,9 +425,11 @@ How a new session picks its account (`server/lib/claude-pool.js`): - A token more than a year old is skipped. - A resumed session picks an account again, like a new one. - **Switching a user to one account.** An admin can take one user off the - ranking: **Switch a user's account** in the Account pool card (or - `agenthub accounts pin [--tool codex]`, `unpin ` for - automatic again; their own password again, audited). Every session in a + ranking: the pencil (**Edit**) on that account's row in the Account pool + card — tick the users who run on it, untick to send one back to automatic (or + `agenthub accounts pin [--tool codex]`, `unpin `; their + own password again, each change audited). A user ticked on one account + moves off any other. Every session in a workspace that user OWNS then runs on that account, even when it is full; other users are not affected, and the account is not reserved for them. - **A Claude login switches at once, without a restart.** The server first diff --git a/server/__tests__/server-mode-account-pool.test.js b/server/__tests__/server-mode-account-pool.test.js index 13a8ecd8..0360664b 100644 --- a/server/__tests__/server-mode-account-pool.test.js +++ b/server/__tests__/server-mode-account-pool.test.js @@ -201,6 +201,42 @@ describe("pinning a user to one account", () => { assert.equal(pins.pinFor(miaId, "claude"), null); }); + it("sets exactly who runs on one account from its Edit", async () => { + const boss = await login("boss", BOSS_PASS); + const id = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id; + claudePool.addAccount("team-y", `sk-ant-oat01-${"y".repeat(40)}`); + pins.setPin(id("ana"), "claude", "team-1"); + const edit = (account, userIds, extra = {}) => + call("PUT", `/api/accounts/${account}/users`, { + cookie: boss, + body: { tool: "claude", userIds, currentPassword: BOSS_PASS, ...extra }, + }); + const r = await edit("team-y", [id("mia"), id("ana")]); + assert.equal(r.status, 200); + assert.deepEqual(r.body.added.map((u) => [u.username, u.switched]).sort(), [ + ["ana", "next-session"], + ["mia", "next-session"], + ]); + assert.equal(pins.pinFor(id("ana"), "claude"), "team-y", "moved off team-1"); + const again = await edit("team-y", [id("mia")]); + assert.deepEqual(again.body.added, []); + assert.deepEqual( + again.body.removed.map((u) => u.username), + ["ana"] + ); + assert.equal(pins.pinFor(id("ana"), "claude"), null); + assert.equal(pins.pinFor(id("mia"), "claude"), "team-y"); + const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get(); + assert.match(audit.path, /unpin ana from claude/); + assert.equal((await edit("team-y", [9999])).body.error.code, "ENOUSER"); + assert.equal((await edit("team-y", "x")).body.error.code, "EBADUSER"); + assert.equal((await edit("nope", [])).body.error.code, "ENOACCOUNT"); + assert.equal((await edit("team-y", [], { currentPassword: "x" })).body.error.code, "EREAUTH"); + assert.equal(pins.pinFor(id("mia"), "claude"), "team-y", "a refused edit changes nothing"); + await edit("team-y", []); + claudePool.removeAccount("team-y"); + }); + it("is ignored in local mode and dropped with its user", async () => { const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id; pins.setPin(miaId, "claude", "team-1"); diff --git a/server/lib/account-pins.js b/server/lib/account-pins.js index 08451e7b..23f66f19 100644 --- a/server/lib/account-pins.js +++ b/server/lib/account-pins.js @@ -11,6 +11,8 @@ * written into the user's home right away (`applyPin`), over the pool login * their running panes use, so those move to it without a restart. * + * `setAccountUsers` is the Users page's Edit of one account: who runs on it. + * * Server mode only: in local mode there are no users and nothing is pinned. * Removing the account drops its pins (`dropAccount`); deleting the user drops * theirs (ON DELETE CASCADE, and `auth.deleteUser`). @@ -81,6 +83,41 @@ function setPin(userId, tool, account) { return { userId: user.id, username: user.username, account: name }; } +/** + * The Users page's Edit of one account: exactly `userIds` run on `account` + * afterwards. A user pinned to another account of the tool moves here; one + * pinned here but left out goes back to the balancer. One transaction. + * Returns `{added, removed}` (each `[{userId, username}]`). + */ +function setAccountUsers(tool, account, userIds) { + validTool(tool); + const name = String(account ?? ""); + if (!poolOf(tool).hasAccount(name)) { + throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`); + } + if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) { + throw err("EBADUSER", "userIds: an array of user ids"); + } + const want = [...new Set(userIds)]; + const users = want.map((id) => { + const u = db().prepare("SELECT id, username FROM users WHERE id = ?").get(id); + if (!u) throw err("ENOUSER", `no user ${id}`); + return u; + }); + const before = listPins(tool); + const here = new Set(before.filter((p) => p.account === name).map((p) => p.userId)); + const added = users.filter((u) => !here.has(u.id)); + const removed = before.filter((p) => p.account === name && !want.includes(p.userId)); + db().transaction(() => { + for (const u of added) setPin(u.id, tool, name); + for (const p of removed) setPin(p.userId, tool, null); + })(); + return { + added: added.map((u) => ({ userId: u.id, username: u.username })), + removed: removed.map((p) => ({ userId: p.userId, username: p.username })), + }; +} + /** * Make a pin take effect now where it can: a Claude login is written into * the user's home at once (claude-pool `switchHome`), so their running panes @@ -104,4 +141,4 @@ function dropAccount(tool, account) { } } -module.exports = { TOOLS, pinFor, listPins, setPin, applyPin, dropAccount }; +module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount }; diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js index f13ade48..8c9b7c59 100644 --- a/server/openapi-extra/auth.js +++ b/server/openapi-extra/auth.js @@ -6,6 +6,7 @@ * - GET /api/accounts (Auth) — the Claude and Codex account pools, 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 * - 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 @@ -771,6 +772,41 @@ const paths = { }, }, }, + "/api/accounts/{name}/users": { + put: { + tags: ["Auth"], + operationId: "setPoolAccountUsers", + summary: + "Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user's home at once", + parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["tool", "userIds", "currentPassword"], + properties: { + tool: { type: "string", enum: ["claude", "codex"] }, + userIds: { type: "array", items: { type: "integer" } }, + currentPassword: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: + "`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session", + }, + 400: err("EBADTOOL, EBADUSER"), + 403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"), + 404: err("ENOUSER, ENOACCOUNT"), + 429: err("ETOOMANY — too many wrong passwords"), + }, + }, + }, "/api/accounts/{name}": { delete: { tags: ["Auth"], diff --git a/server/routes/accounts.js b/server/routes/accounts.js index 01fca68f..198cc197 100644 --- a/server/routes/accounts.js +++ b/server/routes/accounts.js @@ -19,7 +19,9 @@ * `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`); `GET /` lists the pins under `pins`. + * 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`. * `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change * they need the admin's own password again (`currentPassword`), are audited * (the account NAME only), and refuse in local mode. The secret only goes IN: @@ -77,6 +79,7 @@ const STATUS = { ELOCALMODE: 403, EREAUTH: 403, EBADTOOL: 400, + EBADUSER: 400, EBADNAME: 400, EBADTOKEN: 400, ENOACCOUNT: 404, @@ -102,7 +105,9 @@ function poolChange(handler) { } await auth.confirmPassword(req.user, body.currentPassword); const out = await handler(req, body); - recordAccountChange(req.user.id, out.target ?? null, req.method, out.action); + for (const a of out.audits || [{ target: out.target, action: out.action }]) { + recordAccountChange(req.user.id, a.target ?? null, req.method, a.action); + } res.status(out.status || 200).json(out.json); } catch (e) { const status = STATUS[e.code]; @@ -216,15 +221,9 @@ router.put( "/pins", poolChange(async (_req, body) => { const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null); - let switched = null; - if (pin.account) { - try { - switched = await pins.applyPin(pin.userId, body.tool); - } catch (e) { - console.warn(`[accounts] switch ${pin.username} → ${pin.account}: ${e.message}`); - switched = "next-session"; - } - } + const switched = pin.account + ? await applyNow(pin.userId, pin.username, body.tool, pin.account) + : null; return { json: { pin, switched }, target: pin.userId, @@ -235,6 +234,41 @@ router.put( }) ); +/** Switch an added user now; a failed switch still leaves the pin for the next spawn. */ +async function applyNow(userId, username, tool, account) { + try { + return await pins.applyPin(userId, tool); + } catch (e) { + console.warn(`[accounts] switch ${username} → ${account}: ${e.message}`); + return "next-session"; + } +} + +router.put( + "/:name/users", + poolChange(async (req, body) => { + const account = req.params.name; + const { added, removed } = pins.setAccountUsers(body.tool, account, body.userIds); + const switched = []; + for (const u of added) { + switched.push({ ...u, switched: await applyNow(u.userId, u.username, body.tool, account) }); + } + return { + json: { added: switched, removed }, + audits: [ + ...added.map((u) => ({ + target: u.userId, + action: `pool: pin ${u.username} to ${body.tool} account ${account}`, + })), + ...removed.map((u) => ({ + target: u.userId, + action: `pool: unpin ${u.username} from ${body.tool}`, + })), + ], + }; + }) +); + router.delete( "/:name", poolChange((req, body) => {