feat(pool): pin a user to one Claude/Codex pool account

An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
This commit is contained in:
2026-10-19 10:05:19 +07:00
parent aa8ac6cad9
commit 9c98638ec0
20 changed files with 776 additions and 39 deletions
+222 -2
View File
@@ -8,20 +8,23 @@
* `.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.
* never sends it back. An admin can also pin a user to one account
* (`PUT /api/accounts/pins`): that user's new sessions skip the balancer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { KeyRound, Pin, PinOff, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
api,
type AccountPin,
type AccountPool,
type PoolAccountInput,
type PoolProfile,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
@@ -59,6 +62,8 @@ function rowsOf(pool: AccountPool): Row[] {
];
}
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
@@ -72,6 +77,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Row | null>(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 load = useCallback(
(force = false) =>
@@ -91,6 +98,11 @@ 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 (
@@ -112,6 +124,15 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && rows.length > 0 && (
<button
type="button"
onClick={() => setPinning({ tool: "claude" })}
className="btn-secondary"
>
<Pin className="w-4 h-4" /> {t("pool.pinUser")}
</button>
)}
{isAdmin && (
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.add")}
@@ -148,6 +169,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)}
@@ -206,6 +237,33 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</table>
</div>
)}
{pins.length > 0 && (
<div className="border-t border-border px-5 py-3" data-testid="account-pins">
<h3 className="text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pool.pinsTitle")}
</h3>
<ul className="mt-1.5 flex flex-wrap gap-2">
{pins.map((p) => (
<li key={`${p.tool}:${p.userId}`} className="badge gap-1.5 text-xs text-fg-secondary">
<Pin className="h-3 w-3" />
{p.username} → {p.account}
<span className="text-fg-muted">({t(`pool.toolName.${p.tool}`)})</span>
{isAdmin && (
<button
type="button"
onClick={() => setPinning({ tool: p.tool, userId: p.userId })}
title={t("pool.unpin", { name: p.username })}
aria-label={t("pool.unpin", { name: p.username })}
className="text-fg-muted hover:text-fg-primary"
>
<PinOff className="h-3 w-3" />
</button>
)}
</li>
))}
</ul>
</div>
)}
{adding && (
<AddAccountDialog
@@ -228,10 +286,172 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}}
/>
)}
{pinning && pool && (
<PinUserDialog
pool={pool}
initial={pinning}
onClose={() => setPinning(null)}
onDone={(user, account) => {
toast.success(
account
? t("pool.pinDone.pinned", { user, account })
: t("pool.pinDone.unpinned", { user })
);
setPinning(null);
void load();
}}
/>
)}
</section>
);
}
/**
* Pin a user to one account of a tool, or unpin them ("None"). Opened on an
* existing pin it starts on that user with "None", so one click unpins.
*/
function PinUserDialog({
pool,
initial,
onClose,
onDone,
}: {
pool: AccountPool;
initial: { tool: PoolTool; userId?: number };
onClose: () => void;
onDone: (user: string, account: string | null) => void;
}) {
const { t } = useTranslation("users");
const [tool, setTool] = useState<PoolTool>(initial.tool);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [userId, setUserId] = useState<number | null>(initial.userId ?? null);
const [account, setAccount] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const 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 submit = async () => {
if (!ready || busy || userId == null) return;
setBusy(true);
setError(null);
try {
const r = await api.accountPool.pin(tool, userId, account || null, current);
onDone(r.pin.username, r.pin.account);
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.pinTitle")}
message={t("pool.pinHint")}
confirmLabel={t("pool.pinButton")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("pool.tool")}>
<select
className="input w-full text-sm"
value={tool}
onChange={(e) => {
const next = e.target.value as PoolTool;
setTool(next);
setAccount(pinOf(userId, next));
}}
aria-label={t("pool.tool")}
>
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
</select>
</Field>
<Field label={t("pool.user")}>
<select
className="input w-full text-sm"
value={userId ?? ""}
onChange={(e) => {
const id = e.target.value ? Number(e.target.value) : null;
setUserId(id);
setAccount(pinOf(id, tool));
}}
aria-label={t("pool.user")}
>
<option value="">
{users === null ? t("pool.loadingUsers") : t("pool.chooseUser")}
</option>
{users?.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
{u.disabled ? ` (${t("disabled")})` : ""}
</option>
))}
</select>
</Field>
<Field label={t("pool.pinAccount")}>
<select
className="input w-full text-sm"
value={account}
onChange={(e) => setAccount(e.target.value)}
aria-label={t("pool.pinAccount")}
disabled={userId == null}
>
<option value="">{t("pool.noPin")}</option>
{accounts.map((a) => (
<option key={a.name} value={a.name}>
{a.name}
{"email" in a && a.email ? ` — ${a.email}` : ""}
</option>
))}
</select>
</Field>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
type ClaudeSource = "profile" | "credentials" | "token";
function AddAccountDialog({
@@ -5,7 +5,8 @@
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control.
* sees no add or remove control. An admin pins a user to one account and
* unpins them, each with their own password; the pins show on the accounts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
profiles: vi.fn(),
pin: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool } };
return { ...actual, api: { accountPool, users } };
});
import { AccountPoolCard } from "../AccountPoolCard";
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({
profiles: [
{
@@ -158,4 +167,48 @@ describe("AccountPoolCard", () => {
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
);
});
it("pins a user to an account and unpins them, with the admin's own password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
});
accountPool.pin.mockResolvedValue({ pin: { userId: 7, username: "mia", account: "team-1" } });
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
expect(await screen.findByText("Pinned: leo")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /pin a user/i }));
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 === "Save pin"
)!;
expect(confirm).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(confirm);
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 } });
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
await user.click(await screen.findByRole("button", { name: "Unpin 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 === "Save pin")!
);
await waitFor(() =>
expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass")
);
});
});
+23 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
"ENOTFOUND": "That saved login no longer exists.",
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
"EUPSTREAM": "Anthropic could not be reached. Try again."
"EUPSTREAM": "Anthropic could not be reached. Try again.",
"ENOUSER": "That user no longer exists."
},
"source": "From",
"sources": {
@@ -196,7 +197,27 @@
"token": "The sk-ant-… that `claude setup-token` printed.",
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
},
"refreshFailed": "refresh failed"
"refreshFailed": "refresh failed",
"pinned": "Pinned: {{users}}",
"pinUser": "Pin a user",
"pinTitle": "Pin a user to an account",
"pinHint": "That user's new sessions always run on the chosen account instead of the least-used one — even when it is full. Sessions already running keep their account; a login waits until the user's sessions on another login have ended.",
"pinButton": "Save pin",
"user": "User",
"chooseUser": "Choose a user…",
"loadingUsers": "Loading…",
"pinAccount": "Account",
"noPin": "None — the least-used account",
"pinsTitle": "Pinned users",
"unpin": "Unpin {{name}}",
"pinDone": {
"pinned": "{{user}} now runs on {{account}}",
"unpinned": "{{user}} is back on the least-used account"
},
"toolName": {
"claude": "Claude",
"codex": "Codex"
}
},
"delete": {
"button": "Delete user",
+23 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
"ENOUSER": "User đó không còn tồn tại."
},
"source": "Nguồn",
"sources": {
@@ -196,7 +197,27 @@
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
},
"refreshFailed": "làm mới lỗi"
"refreshFailed": "làm mới lỗi",
"pinned": "Đã ghim: {{users}}",
"pinUser": "Ghim user",
"pinTitle": "Ghim user vào một tài khoản",
"pinHint": "Phiên mới của user này luôn 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. Phiên đang chạy giữ nguyên tài khoản; một login sẽ chờ tới khi các phiên của user trên login khác kết thúc.",
"pinButton": "Lưu ghim",
"user": "User",
"chooseUser": "Chọn user…",
"loadingUsers": "Đang tải…",
"pinAccount": "Tài khoản",
"noPin": "Không ghim — tài khoản ít tải nhất",
"pinsTitle": "User đã ghim",
"unpin": "Bỏ ghim {{name}}",
"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"
},
"toolName": {
"claude": "Claude",
"codex": "Codex"
}
},
"delete": {
"button": "Xoá user",
+18
View File
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
quotaPercent: number | null;
full: boolean;
};
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
export type AccountPin = {
userId: number;
username: string;
account: string;
pinnedAt?: string;
};
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
/** Per tool; absent from an older server, empty in local mode. */
pins?: { claude: AccountPin[]; codex: AccountPin[] };
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
@@ -1052,6 +1061,15 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** PUT /api/accounts/pins — admin: pin a user to one account (`null` unpins), password again. */
pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) =>
request<{ pin: { userId: number; username: string; account: string | null } }>(
"/accounts/pins",
{
method: "PUT",
body: JSON.stringify({ tool, userId, account, currentPassword }),
}
),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {