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
+209 -2
View File
@@ -4,10 +4,14 @@
* - POST /api/auth/login (Auth)
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
* (Auth) — Settings → Account: the account's own profile, password and logins
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -38,6 +42,21 @@ const schemas = {
username: { type: "string" },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
displayName: { type: "string", nullable: true },
email: { type: "string", nullable: true },
createdAt: { type: "string", nullable: true },
},
},
MyAccount: {
type: "object",
required: ["mode"],
properties: {
mode: { type: "string", enum: ["local", "server"] },
user: { $ref: "#/components/schemas/AuthUser" },
workspaces: { type: "integer" },
sessions: { type: "integer" },
logins: { type: "integer", description: "Unexpired logins of the account" },
lastLoginAt: { type: "string", nullable: true },
},
},
AuthMe: {
@@ -88,6 +107,9 @@ const changeResponses = {
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
};
const accountBody = {
"application/json": { schema: { $ref: "#/components/schemas/MyAccount" } },
};
const prefsBody = {
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
};
@@ -182,6 +204,85 @@ const paths = {
responses: { 200: { description: "Marked", content: prefsBody }, 401: err("EAUTH") },
},
},
"/api/me/account": {
get: {
tags: ["Auth"],
operationId: "getMyAccount",
summary: 'Settings → Account: the signed-in account (local mode: {mode: "local"})',
responses: {
200: { description: "Account", content: accountBody },
401: err("EAUTH"),
},
},
patch: {
tags: ["Auth"],
operationId: "updateMyAccount",
summary:
'The account\'s own display name and email (""/null clears). Login cookie only, audited',
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
displayName: { type: "string", nullable: true, maxLength: 100 },
email: { type: "string", nullable: true, maxLength: 254 },
},
},
},
},
},
responses: {
200: { description: "Updated", content: accountBody },
400: err("EBADFIELD / EBADEMAIL"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/me/password": {
post: {
tags: ["Auth"],
operationId: "changeMyPassword",
summary:
"Change the account's own password: the current one required (throttled); this login stays, every other ends",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword", "newPassword"],
properties: {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 1 },
},
},
},
},
},
responses: {
200: { description: "Changed ({ok: true})" },
400: err("EBADPASSWORD"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("EREAUTH (current password wrong) / ELOCALMODE"),
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
},
},
},
"/api/me/sign-out-others": {
post: {
tags: ["Auth"],
operationId: "signOutMyOtherLogins",
summary: "End every login of the account but this one; answers {ended}",
responses: {
200: { description: "Ended" },
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/users": {
get: {
tags: ["Auth"],
@@ -263,17 +364,38 @@ const paths = {
},
},
"/api/users/{id}": {
get: {
tags: ["Auth"],
operationId: "getServerUser",
summary: "One account with its logins and the workspaces it owns (admin/analyst)",
parameters: [idParam],
responses: {
200: { description: "Account" },
403: err("EADMIN — a developer"),
404: err("ENOUSER"),
},
},
patch: {
tags: ["Auth"],
operationId: "updateServerUser",
summary:
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
"Admin (Edit user): display name, email, role, disabled, a new password, workspaces given or taken back, sign out everywhere — every field validated before any is written. Never your own role or disable (ESELF)",
parameters: [idParam],
requestBody: changeBody({
displayName: { type: "string", nullable: true, maxLength: 100 },
email: { type: "string", nullable: true, maxLength: 254 },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
password: { type: "string", minLength: 1 },
addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] },
}),
responses: changeResponses,
responses: {
...changeResponses,
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
},
},
},
"/api/users/{id}/password": {
@@ -322,6 +444,8 @@ const paths = {
session_id: { type: "string", nullable: true },
reader: { type: "string", nullable: true },
owner: { type: "string", nullable: true },
reader_display: { type: "string", nullable: true },
owner_display: { type: "string", nullable: true },
},
},
},
@@ -401,6 +525,89 @@ const paths = {
},
},
},
post: {
tags: ["Auth"],
operationId: "addPoolAccount",
summary:
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
name: { type: "string", description: "Defaults to the profile's name" },
profile: {
type: "string",
description: "Claude: a saved switch-user profile, moved into the pool",
},
credentials: {
type: "string",
description: "Claude: the text of a .credentials.json",
},
token: {
type: "string",
description: "Claude: a setup-token; Codex: an API key or auth.json text",
},
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
201: { description: "`{account: {name, addedAt, …}}` — no token" },
400: err("EBADTOOL, EBADNAME, EBADTOKEN"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
409: err(
"EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile"
),
502: err("EUPSTREAM — Anthropic could not be reached"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/profiles": {
get: {
tags: ["Auth"],
operationId: "listPoolProfiles",
summary: "Saved switch-user profiles an admin may move into the Claude pool (no token)",
responses: {
200: { description: "`{profiles: [{name, label, email, subscriptionType, active}]}`" },
},
},
},
"/api/accounts/{name}": {
delete: {
tags: ["Auth"],
operationId: "removePoolAccount",
summary: "Admin: remove an account from the pool, with their own password again",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: { description: "`{ok: true}`" },
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOACCOUNT"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/auth/me": {
get: {