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:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
+105 -4
View File
@@ -18,6 +18,10 @@
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
* An account also carries an optional display name and email
* (`validateProfile`), which its owner edits in Settings → Account without
* that step-up; changing their OWN password there needs the old one and
* keeps the login it was changed from (`setPassword`'s `keepToken`).
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
@@ -60,6 +64,8 @@ const ADMIN_WRITE_PREFIXES = [
"/reports",
"/pets",
"/users",
// The Claude/Codex account pool: tokens every member's panes run on.
"/accounts",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
];
@@ -129,7 +135,57 @@ function validatePassword(password) {
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
const publicUser = (u) =>
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
u && {
id: u.id,
username: u.username,
role: u.role,
disabled: Boolean(u.disabled),
displayName: u.display_name ?? null,
email: u.email ?? null,
createdAt: u.created_at ?? null,
};
const MAX_DISPLAY_NAME = 100;
const MAX_EMAIL = 254;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* The profile fields present in `body`, normalized: trimmed, `""`/`null` ⇒
* null (clearing is legitimate). Fields absent from `body` are absent from the
* result, so a caller writes only what was sent. Throws EBADFIELD/EBADEMAIL.
*/
function validateProfile(body) {
const out = {};
if (body.displayName !== undefined) {
const v = body.displayName;
if (v !== null && typeof v !== "string") throw err("EBADFIELD", "displayName: a string");
const n = (v || "").trim();
// eslint-disable-next-line no-control-regex
if (n.length > MAX_DISPLAY_NAME || /[\u0000-\u001f\u007f]/.test(n)) {
throw err("EBADFIELD", `displayName: at most ${MAX_DISPLAY_NAME} printable characters`);
}
out.display_name = n || null;
}
if (body.email !== undefined) {
const v = body.email;
if (v !== null && typeof v !== "string") throw err("EBADEMAIL", "email: a string");
const e = (v || "").trim();
if (e && (e.length > MAX_EMAIL || !EMAIL_RE.test(e))) {
throw err("EBADEMAIL", "email: not a valid address");
}
out.email = e || null;
}
return out;
}
/** Write `validateProfile`'s result for one account. */
function setProfile(userId, fields) {
for (const [col, value] of Object.entries(fields)) {
// `col` is one of validateProfile's two fixed names, never request text.
if (col !== "display_name" && col !== "email") continue;
sql(`UPDATE users SET ${col} = ? WHERE id = ?`).run(value, userId);
}
}
function listUsers() {
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
@@ -166,12 +222,33 @@ function requireUser(username) {
return u;
}
/** New password; every login of that user ends. */
async function setPassword(username, password) {
/** New password; every login of that user ends — but `keepToken`'s, when
* the user changed their own password from that login. */
async function setPassword(username, password, { keepToken } = {}) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
endSessions(u.id, keepToken);
}
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
function endSessions(userId, exceptToken) {
if (exceptToken) {
return sql("DELETE FROM auth_sessions WHERE user_id = ? AND token_hash != ?").run(
userId,
sha256(exceptToken)
).changes;
}
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
}
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
function loginStats(userId) {
const r = sql(
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
WHERE user_id = ? AND expires_at > ?`
).get(userId, new Date().toISOString());
return { logins: r.n, lastLoginAt: r.last || null };
}
function setRole(username, role) {
@@ -232,6 +309,23 @@ function assignWorkspace(laneId, username) {
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
}
/** Take a workspace back from the user who owns it: it is left with no owner
* (only staff see it) until it is given to someone. ENOTOWNED when `username`
* does not own it — this never takes one from a third user. */
function unassignWorkspace(laneId, username) {
const u = requireUser(username);
const lane = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
if (!lane) throw err("ENOLANE", `no workspace ${laneId}`);
if (lane.owner_id !== u.id) {
throw err("ENOTOWNED", `workspace ${laneId} is not ${u.username}'s`);
}
sql("UPDATE lanes SET owner_id = NULL WHERE id = ? AND owner_id = ?").run(Number(laneId), u.id);
}
/** The workspaces `userId` owns: `[{id, title, cwd}]`. */
const ownedWorkspaces = (userId) =>
sql("SELECT id, title, cwd FROM lanes WHERE owner_id = ? ORDER BY id").all(userId);
const hasAdmin = () =>
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
@@ -510,6 +604,13 @@ module.exports = {
setRole,
setDisabled,
assignWorkspace,
unassignWorkspace,
ownedWorkspaces,
validateProfile,
setProfile,
validatePassword,
endSessions,
loginStats,
hasAdmin,
userFromRequest,
login,