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:
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user