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>
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|