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:
2026-10-19 10:23:44 +07:00
parent 9c98638ec0
commit 776ad6466f
16 changed files with 208 additions and 89 deletions
+15 -11
View File
@@ -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")