0c2f49876d
- 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>
218 lines
7.8 KiB
JavaScript
218 lines
7.8 KiB
JavaScript
/**
|
|
* @file accounts.js
|
|
* @description `GET /api/accounts` — the Claude account pool as the balancer
|
|
* 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 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();
|
|
|
|
router.get("/", async (req, res) => {
|
|
try {
|
|
await pool.refreshQuotas({ force: req.query.force === "1" });
|
|
if (req.query.force === "1") codexPool.resetQuota();
|
|
res.json({
|
|
accounts: pool.poolStatus(),
|
|
codex: codexPool.poolStatus(),
|
|
blockPercent: pool.BLOCK_PERCENT,
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
|
|
}
|
|
});
|
|
|
|
/** 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;
|