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
+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) => {