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,263 @@
|
||||
/**
|
||||
* @file server-mode-user-edit.test.js
|
||||
* @description The Users page's Edit user form (`GET`/`PATCH /api/users/:id`)
|
||||
* and Settings → Account (`/api/me/account`, `/api/me/password`,
|
||||
* `/api/me/sign-out-others`): one PATCH validates every field before any is
|
||||
* written, sets display name/email/role/password/workspaces/sign-out together
|
||||
* with the admin's own password, never takes a workspace from a third user; a
|
||||
* user edits their own profile, changes their own password only with the old
|
||||
* one (keeping the login they changed it from), signs out other devices, and a
|
||||
* pane token may not do any of it.
|
||||
* @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-user-edit-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
async function call(method, p, { cookie, body, headers } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
const lane = (cwd, owner = null) =>
|
||||
Number(
|
||||
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
|
||||
.lastInsertRowid
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.leo = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
ids.free = lane("/w/free");
|
||||
ids.miaLane = lane("/w/mia", ids.mia);
|
||||
ids.leoLane = lane("/w/leo", ids.leo);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Edit user (PATCH /api/users/:id)", () => {
|
||||
it("reads one account with its workspaces and logins, never a hash", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
await login("mia", "member-pass-1");
|
||||
const r = await call("GET", `/api/users/${ids.mia}`, { cookie: boss });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.user.username, "mia");
|
||||
assert.equal(r.body.user.logins, 1);
|
||||
assert.ok(r.body.user.lastLoginAt);
|
||||
assert.ok(r.body.user.createdAt);
|
||||
assert.deepEqual(
|
||||
r.body.workspaces.map((w) => w.id),
|
||||
[ids.miaLane]
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"));
|
||||
});
|
||||
|
||||
it("applies profile, role, workspaces and password in one request", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: {
|
||||
currentPassword: BOSS_PASS,
|
||||
displayName: " Mia Tran ",
|
||||
email: "mia@example.com",
|
||||
role: "analyst",
|
||||
password: "mia-new-pass",
|
||||
addWorkspaces: [ids.free],
|
||||
removeWorkspaces: [ids.miaLane],
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(r.body.user.displayName, "Mia Tran");
|
||||
assert.equal(r.body.user.email, "mia@example.com");
|
||||
assert.equal(r.body.user.role, "analyst");
|
||||
assert.equal(r.body.user.logins, 0, "a new password ends the user's logins");
|
||||
const owners = db
|
||||
.prepare("SELECT id, owner_id FROM lanes WHERE id IN (?, ?)")
|
||||
.all(ids.free, ids.miaLane);
|
||||
assert.deepEqual(Object.fromEntries(owners.map((o) => [o.id, o.owner_id])), {
|
||||
[ids.free]: ids.mia,
|
||||
[ids.miaLane]: null,
|
||||
});
|
||||
assert.ok(await login("mia", "mia-new-pass"));
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(ids.mia).path;
|
||||
assert.match(audit, /display name, email, role analyst/);
|
||||
assert.ok(!audit.includes("mia-new-pass"), "the audit never carries the password");
|
||||
});
|
||||
|
||||
it("writes nothing when one field is bad", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const before = db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo);
|
||||
for (const [body, code] of [
|
||||
[{ displayName: "Leo", email: "not-an-email" }, "EBADEMAIL"],
|
||||
[{ displayName: "Leo", password: "" }, "EBADPASSWORD"],
|
||||
[{ displayName: "Leo", role: "root" }, "EBADROLE"],
|
||||
[{ displayName: "Leo", addWorkspaces: [99999] }, "ENOLANE"],
|
||||
[{ displayName: "Leo", addWorkspaces: ["1"] }, "EBADFIELD"],
|
||||
[{ displayName: "Leo", signOut: "yes" }, "EBADFIELD"],
|
||||
// Never takes a workspace from a third user.
|
||||
[{ displayName: "Leo", removeWorkspaces: [ids.free] }, "ENOTOWNED"],
|
||||
]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||
cookie: boss,
|
||||
body: { ...body, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.body?.error?.code, code, JSON.stringify(body));
|
||||
}
|
||||
assert.deepEqual(db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo), before);
|
||||
assert.equal(
|
||||
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.free).owner_id,
|
||||
ids.mia
|
||||
);
|
||||
});
|
||||
|
||||
it("needs the admin's own password, and keeps ESELF", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||
cookie: boss,
|
||||
body: { displayName: "x" },
|
||||
});
|
||||
assert.equal(noPass.body.error.code, "EREAUTH");
|
||||
for (const body of [{ role: "developer" }, { disabled: true }]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { ...body, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.body.error.code, "ESELF");
|
||||
}
|
||||
});
|
||||
|
||||
it("signs a user out everywhere, but an admin editing themselves keeps this login", async () => {
|
||||
await login("leo", "member-pass-2");
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const other = await login("boss", BOSS_PASS);
|
||||
const r = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||
cookie: boss,
|
||||
body: { signOut: true, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.body.user.logins, 0);
|
||||
const self = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { signOut: true, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(self.status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: boss })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("display names reach the workspace list and the audit log", () => {
|
||||
it("carries owner_display_name beside owner_name, and audit display names", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const lanes = (await call("GET", "/api/lanes", { cookie: boss })).body.lanes;
|
||||
const free = lanes.find((l) => l.id === ids.free);
|
||||
assert.equal(free.owner_name, "mia");
|
||||
assert.equal(free.owner_display_name, "Mia Tran");
|
||||
const audit = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
||||
const row = audit.find((a) => a.owner === "mia");
|
||||
assert.equal(row.owner_display, "Mia Tran");
|
||||
assert.equal(row.reader, "boss");
|
||||
assert.equal(row.reader_display, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings → Account (/api/me)", () => {
|
||||
it("shows the account, and edits its own display name and email", async () => {
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
const g = await call("GET", "/api/me/account", { cookie: leo });
|
||||
assert.equal(g.body.mode, "server");
|
||||
assert.equal(g.body.user.username, "leo");
|
||||
assert.equal(g.body.workspaces, 1);
|
||||
assert.equal(g.body.logins, 1);
|
||||
const p = await call("PATCH", "/api/me/account", {
|
||||
cookie: leo,
|
||||
body: { displayName: "Leo N", email: "" },
|
||||
});
|
||||
assert.equal(p.status, 200);
|
||||
assert.equal(p.body.user.displayName, "Leo N");
|
||||
assert.equal(p.body.user.email, null);
|
||||
const bad = await call("PATCH", "/api/me/account", { cookie: leo, body: { email: "x@" } });
|
||||
assert.equal(bad.body.error.code, "EBADEMAIL");
|
||||
});
|
||||
|
||||
it("changes its own password only with the old one, keeping this login", async () => {
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
const other = await login("leo", "member-pass-2");
|
||||
const wrong = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||
assert.ok(await login("leo", "leo-new-pass"));
|
||||
});
|
||||
|
||||
it("signs out other devices", async () => {
|
||||
const a = await login("leo", "leo-new-pass");
|
||||
const b = await login("leo", "leo-new-pass");
|
||||
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
|
||||
assert.ok(r.body.ended >= 1);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: b })).status, 401);
|
||||
});
|
||||
|
||||
it("refuses a pane token: an agent may not change its owner's login", async () => {
|
||||
const pane = auth.newPaneToken();
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
|
||||
VALUES (?, 'interactive', '/w/leo', 'running', ?, ?)`
|
||||
).run(`agenthub-lane-${ids.leoLane}`, ids.leoLane, pane.hash);
|
||||
const r = await call("POST", "/api/me/sign-out-others", {
|
||||
headers: { [auth.PANE_HEADER]: pane.token },
|
||||
});
|
||||
assert.notEqual(r.status, 200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user