feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned Claude login into the user's home right away (claude-pool.switchHome): every home's newer pair is copied back first, the member's own login is never written over, and no pane is restarted — running sessions take the new pair at their next credentials read. A live home stays on the login it holds, so unpinning does not flip it back. The Users page calls it "Switch a user's account" and says what happened.
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
* `.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 pin a user to one account
|
||||
* (`PUT /api/accounts/pins`): that user's new sessions skip the balancer.
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -291,11 +292,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
pool={pool}
|
||||
initial={pinning}
|
||||
onClose={() => setPinning(null)}
|
||||
onDone={(user, account) => {
|
||||
toast.success(
|
||||
account
|
||||
? t("pool.pinDone.pinned", { user, account })
|
||||
: t("pool.pinDone.unpinned", { user })
|
||||
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 })
|
||||
);
|
||||
setPinning(null);
|
||||
void load();
|
||||
@@ -307,8 +311,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function PinUserDialog({
|
||||
pool,
|
||||
@@ -319,7 +323,7 @@ function PinUserDialog({
|
||||
pool: AccountPool;
|
||||
initial: { tool: PoolTool; userId?: number };
|
||||
onClose: () => void;
|
||||
onDone: (user: string, account: string | null) => void;
|
||||
onDone: (user: string, account: string | null, switched: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>(initial.tool);
|
||||
@@ -348,7 +352,7 @@ function PinUserDialog({
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.accountPool.pin(tool, userId, account || null, current);
|
||||
onDone(r.pin.username, r.pin.account);
|
||||
onDone(r.pin.username, r.pin.account, r.switched ?? null);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
|
||||
@@ -5,8 +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. An admin pins a user to one account and
|
||||
* unpins them, each with their own password; the pins show on the accounts.
|
||||
* sees no add or remove control. An admin switches a user to one account and
|
||||
* back to automatic, each with their own password; it shows on the accounts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -173,17 +173,20 @@ describe("AccountPoolCard", () => {
|
||||
...POOL,
|
||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
||||
});
|
||||
accountPool.pin.mockResolvedValue({ pin: { userId: 7, username: "mia", account: "team-1" } });
|
||||
accountPool.pin.mockResolvedValue({
|
||||
pin: { userId: 7, username: "mia", account: "team-1" },
|
||||
switched: "switched",
|
||||
});
|
||||
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 }));
|
||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /switch a user's account/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"
|
||||
(b) => b.textContent === "Switch"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
@@ -198,14 +201,17 @@ describe("AccountPoolCard", () => {
|
||||
...POOL,
|
||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
||||
});
|
||||
accountPool.pin.mockResolvedValue({ pin: { userId: 8, username: "leo", account: null } });
|
||||
accountPool.pin.mockResolvedValue({
|
||||
pin: { userId: 8, username: "leo", account: null },
|
||||
switched: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await user.click(await screen.findByRole("button", { name: "Unpin leo" }));
|
||||
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 === "Save pin")!
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Switch")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass")
|
||||
|
||||
@@ -198,21 +198,24 @@
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"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",
|
||||
"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": "None — the least-used account",
|
||||
"pinsTitle": "Pinned users",
|
||||
"unpin": "Unpin {{name}}",
|
||||
"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"
|
||||
"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",
|
||||
|
||||
@@ -198,21 +198,24 @@
|
||||
"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",
|
||||
"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",
|
||||
"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": "Không ghim — tài khoản ít tải nhất",
|
||||
"pinsTitle": "User đã ghim",
|
||||
"unpin": "Bỏ ghim {{name}}",
|
||||
"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"
|
||||
"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",
|
||||
|
||||
@@ -1061,15 +1061,16 @@ 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. */
|
||||
/** 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) =>
|
||||
request<{ pin: { userId: number; username: string; account: string | null } }>(
|
||||
"/accounts/pins",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tool, userId, account, currentPassword }),
|
||||
}
|
||||
),
|
||||
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", {
|
||||
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)}`, {
|
||||
|
||||
Reference in New Issue
Block a user