feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+187
-2
@@ -4,14 +4,31 @@
|
||||
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
||||
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
||||
* key or a token. `?force=1` re-reads every quota first. Accounts are added and
|
||||
* removed only from the host's shell (`agenthub accounts …`).
|
||||
* key or a token. `?force=1` re-reads every quota first.
|
||||
*
|
||||
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
||||
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
||||
* `DELETE /:name` (`{tool}`). A Claude account is one of: `profile` — a login
|
||||
* saved by the switch-user profiles (cc-profiles.js), MOVED into the pool (its
|
||||
* single-use refresh token may have only one holder, so the profile is
|
||||
* deleted; the account the host's own CLI is logged into moves in with its
|
||||
* live login and the host's home becomes one more placement the server
|
||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
||||
* `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:
|
||||
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const pool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -29,4 +46,172 @@ router.get("/", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Saved switch-user profiles an admin may move into the pool — never a token. */
|
||||
router.get("/profiles", (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
profiles: profiles.listProfiles().items.map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
email: p.email,
|
||||
subscriptionType: p.subscriptionType,
|
||||
active: p.active,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: e.code || "EPROFILES", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
const STATUS = {
|
||||
ENOTFOUND: 404,
|
||||
EACTIVE: 409,
|
||||
ELOCALMODE: 403,
|
||||
EREAUTH: 403,
|
||||
EBADTOOL: 400,
|
||||
EBADNAME: 400,
|
||||
EBADTOKEN: 400,
|
||||
ENOACCOUNT: 404,
|
||||
EEXISTS: 409,
|
||||
ETOOMANY: 429,
|
||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||
EUPSTREAM: 502,
|
||||
};
|
||||
|
||||
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||
function poolChange(handler) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
if (!auth.isServerMode() || !req.user) {
|
||||
throw Object.assign(new Error("the pool is changed from the host's shell in local mode"), {
|
||||
code: "ELOCALMODE",
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
if (body.tool !== "claude" && body.tool !== "codex") {
|
||||
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, null, req.method, out.action);
|
||||
res.status(out.status || 200).json(out.json);
|
||||
} catch (e) {
|
||||
const status = STATUS[e.code];
|
||||
if (!status) return res.status(500).json({ error: { message: e.message } });
|
||||
res.status(status).json({ error: { code: e.code, message: e.message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pasted `.credentials.json` (or Keychain item) → `{oauth, account}`. The
|
||||
* file names no account, so Anthropic is asked whose token it is (refreshing
|
||||
* an expired one first — what the exchange returns is what gets stored):
|
||||
* the pool copies a member's refresh back only when it is that same account.
|
||||
*/
|
||||
async function pastedLogin(text) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(String(text || ""));
|
||||
} catch {
|
||||
throw Object.assign(new Error("not valid JSON — paste the whole .credentials.json"), {
|
||||
code: "EBADTOKEN",
|
||||
});
|
||||
}
|
||||
let oauth = parsed?.claudeAiOauth || parsed;
|
||||
if (!oauth?.accessToken || !oauth?.refreshToken) return { oauth }; // addLoginAccount says why
|
||||
const usage = require("../lib/cc-usage");
|
||||
let owner = await usage.fetchOwner(oauth.accessToken);
|
||||
if (!owner) {
|
||||
oauth = await usage.exchangeRefresh(oauth);
|
||||
owner = await usage.fetchOwner(oauth.accessToken);
|
||||
}
|
||||
if (!owner) {
|
||||
throw Object.assign(new Error("Anthropic did not accept this login"), { code: "EBADTOKEN" });
|
||||
}
|
||||
return { oauth, account: { accountUuid: owner.uuid, emailAddress: owner.email } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a saved profile into the pool: the pool becomes its one holder. The
|
||||
* account this machine's own Claude Code is logged into moves in with the
|
||||
* LIVE login (the saved copy's refresh token is spent once the CLI refreshed)
|
||||
* and keeps running here: the host's home becomes one more placement the
|
||||
* server refreshes and pushes into. A macOS Keychain login cannot be written
|
||||
* that way (EACTIVE).
|
||||
*/
|
||||
function fromProfile(name, poolName) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
|
||||
const active = profiles.listProfiles().items.find((p) => p.name === name)?.active;
|
||||
let oauth = stored.credentials;
|
||||
if (active) {
|
||||
const live = profiles.readActiveAuth();
|
||||
if (live.keychain || !live.oauth) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"this machine's Claude Code keeps that login in the macOS Keychain — switch it to another profile first"
|
||||
),
|
||||
{ code: "EACTIVE" }
|
||||
);
|
||||
}
|
||||
oauth = live.oauth;
|
||||
}
|
||||
const account = pool.addLoginAccount(poolName || name, {
|
||||
oauth,
|
||||
account: stored.account || null,
|
||||
userID: stored.userID || null,
|
||||
hostHolds: Boolean(active),
|
||||
});
|
||||
// The only saved copy of the live account is not deletable (EACTIVE) — and
|
||||
// need not be: removing the file is enough, the pool holds the login now.
|
||||
require("node:fs").rmSync(require("node:path").join(profiles.profilesDir(), `${name}.json`), {
|
||||
force: true,
|
||||
});
|
||||
return account;
|
||||
}
|
||||
|
||||
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
|
||||
function codexSecret(token) {
|
||||
const text = String(token || "").trim();
|
||||
if (!text.startsWith("{")) return { apiKey: text };
|
||||
try {
|
||||
return { auth: JSON.parse(text) };
|
||||
} catch {
|
||||
throw Object.assign(new Error("not valid JSON — paste the whole auth.json"), {
|
||||
code: "EBADTOKEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||
: pool.addAccount(body.name, body.token);
|
||||
return {
|
||||
status: 201,
|
||||
json: { account },
|
||||
action: `pool: add ${body.tool} account ${account.name}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:name",
|
||||
poolChange((req, body) => {
|
||||
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
|
||||
return {
|
||||
json: { ok: true },
|
||||
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user