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:
+105
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user