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:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file server-mode-account-pool.test.js
|
||||
* @description The Claude/Codex account pool from the Users page: only an
|
||||
* admin may add or remove an account, each change needs the admin's OWN
|
||||
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||
* list) ever carries it — the audit log names the account, not its secret, and
|
||||
* in local mode the routes refuse.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-account-pool-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
|
||||
// The quota read would go to the network; the pool's list must not.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
|
||||
const KEY = `sk-proj-${"b".repeat(40)}`;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the account pool from the Users page", () => {
|
||||
it("lets an admin add a Claude account, never answering with its token", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.account.name, "team-1");
|
||||
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
|
||||
assert.equal(claudePool.pickAccount().token, TOKEN, "new panes run on it");
|
||||
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||
assert.deepEqual(
|
||||
list.body.accounts.map((a) => a.name),
|
||||
["team-1"]
|
||||
);
|
||||
assert.ok(!JSON.stringify(list.body).includes(TOKEN));
|
||||
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||
assert.match(audit.path, /add claude account team-1/);
|
||||
assert.ok(!audit.path.includes(TOKEN));
|
||||
});
|
||||
|
||||
it("adds a Codex API key, and removes accounts again", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "codex", name: "cx-1", token: KEY, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
assert.equal(add.body.account.kind, "apikey");
|
||||
assert.ok(!JSON.stringify(add.body).includes(KEY));
|
||||
assert.equal(codexPool.__read("cx-1").key, KEY);
|
||||
const del = await call("DELETE", "/api/accounts/cx-1", {
|
||||
cookie: boss,
|
||||
body: { tool: "codex", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(del.status, 200);
|
||||
assert.equal(codexPool.__read("cx-1"), null);
|
||||
const gone = await call("DELETE", "/api/accounts/cx-1", {
|
||||
cookie: boss,
|
||||
body: { tool: "codex", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
|
||||
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = (body) =>
|
||||
call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-2", token: TOKEN, currentPassword: BOSS_PASS, ...body },
|
||||
});
|
||||
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
|
||||
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("is an admin's only: an analyst reads it, a developer neither reads nor writes", async () => {
|
||||
const ana = await login("ana", "analyst-pass-1");
|
||||
assert.equal((await call("GET", "/api/accounts", { cookie: ana })).status, 200);
|
||||
const w = await call("POST", "/api/accounts", {
|
||||
cookie: ana,
|
||||
body: { tool: "claude", name: "x", token: TOKEN, currentPassword: "analyst-pass-1" },
|
||||
});
|
||||
assert.equal(w.status, 403);
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
|
||||
const d = await call("DELETE", "/api/accounts/team-1", {
|
||||
cookie: mia,
|
||||
body: { tool: "claude", currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(d.status, 403);
|
||||
assert.ok(claudePool.pickAccount(), "team-1 is still there");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user