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.
This commit is contained in:
2026-10-19 10:45:54 +07:00
parent 776ad6466f
commit c042adc51b
11 changed files with 305 additions and 232 deletions
@@ -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");
+38 -1
View File
@@ -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 };
+36
View File
@@ -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"],
+45 -11
View File
@@ -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) => {