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
+144 -8
View File
@@ -540,7 +540,88 @@ export type ServerUserUsage = AuthUser & {
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** Active login sessions (cookies not expired). */
logins?: number;
lastLoginAt?: string | null;
};
/** GET /api/users/:id — one account plus the workspaces it owns. */
export type UserDetail = {
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
workspaces: { id: number; title: string; cwd: string }[];
};
/** PATCH /api/users/:id — every field optional; only what changed is sent. */
export type UserPatch = {
displayName?: string | null;
email?: string | null;
role?: AuthUser["role"];
disabled?: boolean;
password?: string;
addWorkspaces?: number[];
removeWorkspaces?: number[];
signOut?: true;
};
/** GET /api/me/account — the signed-in account's own information. */
export type MyAccount =
| { mode: "local" }
| {
mode: "server";
user: AuthUser & { disabled: boolean };
workspaces: number;
sessions: number;
logins: number;
lastLoginAt: string | null;
};
/** One Claude pool account as `GET /api/accounts` reports it — never its token. */
export type PoolAccount = {
name: string;
/** `token` = a setup-token; `login` = a real login placed in members' homes. */
kind: "token" | "login";
email: string | null;
/** A login the server could not refresh (`ESTALE`: log in again). */
refreshError: string | null;
addedAt: string;
expiresAt: string | null;
expired: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
quotaPercent: number | null;
full: boolean;
};
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
export type CodexPoolAccount = {
name: string;
kind: "apikey" | "chatgpt";
addedAt: string;
livePanes: number;
spend5h: number;
spend7d: number;
quotaPercent: number | null;
full: boolean;
};
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
/** A saved switch-user profile that can be moved into the pool (no token). */
export type PoolProfile = {
name: string;
label: string;
email: string | null;
subscriptionType: string | null;
active: boolean;
};
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
export type PoolAccountInput = {
tool: PoolTool;
name: string;
profile?: string;
credentials?: string;
token?: string;
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
@@ -550,10 +631,21 @@ export type AuditEntry = {
session_id: string | null;
reader: string | null;
owner: string | null;
/** Their display names, when set — shown instead of the usernames. */
reader_display?: string | null;
owner_display?: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
export type AuthUser = {
id: number;
username: string;
role: "admin" | "analyst" | "developer";
/** Optional profile fields — absent from an older server's answers. */
displayName?: string | null;
email?: string | null;
createdAt?: string;
};
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
@@ -702,6 +794,19 @@ export const api = {
}),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
/** GET /api/me/account — this account's own information (server mode). */
account: () => request<MyAccount>("/me/account"),
/** PATCH /api/me/account — this account's display name and email. */
updateAccount: (body: { displayName?: string | null; email?: string | null }) =>
request<MyAccount>("/me/account", { method: "PATCH", body: JSON.stringify(body) }),
/** POST /api/me/password — this login stays, every other one ends. */
changePassword: (currentPassword: string, newPassword: string) =>
request<{ ok: true }>("/me/password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
}),
/** POST /api/me/sign-out-others — end every login of this account but this one. */
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
@@ -721,12 +826,11 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** PATCH /api/users/:id — role and/or disabled (never your own). */
update: (
id: number,
body: { role?: AuthUser["role"]; disabled?: boolean },
currentPassword: string
) =>
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
* sign-out in ONE change (never your own role or disabling yourself). */
update: (id: number, body: UserPatch, currentPassword: string) =>
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
@@ -744,6 +848,26 @@ export const api = {
body: JSON.stringify({ laneId, currentPassword }),
}),
},
/** The Claude/Codex account pool every member's new panes run on
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
accountPool: {
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
/** POST /api/accounts — admin, with their OWN password again. */
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
add: (body: PoolAccountInput, currentPassword: string) =>
request<{ account: { name: string } }>("/accounts", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
method: "DELETE",
body: JSON.stringify({ tool, currentPassword }),
}),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
@@ -934,6 +1058,12 @@ export const api = {
* wherever it lies (local mode only), for an `<img>`. */
readImageUrl: (id: string, path: string) =>
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
/** GET /api/sessions/:id/file — a text file the session itself read or
* wrote, wherever it lies (an output outside the workspace). */
readFile: (id: string, path: string) =>
request<{ text: string; truncated: boolean }>(
`/sessions/${encodeURIComponent(id)}/file?${new URLSearchParams({ path })}`
),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) =>
@@ -2415,6 +2545,8 @@ export const api = {
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
@@ -2975,16 +3107,20 @@ export const api = {
* @returns `{ path, parent, entries, home }` — `parent` is null at the
* root, `home` is the folder the browser's Home place opens.
*/
browse: (path?: string, files?: boolean, hidden?: boolean) => {
browse: (path?: string, files?: boolean, hidden?: boolean, host?: boolean) => {
const q = new URLSearchParams();
if (path) q.set("path", path);
if (files) q.set("files", "1");
if (hidden) q.set("hidden", "1");
if (host) q.set("host", "1");
const qs = q.toString();
return request<{
path: string;
parent: string | null;
home?: string;
/** An admin with their own Linux account (S3): the host account's
* home, opened by `host=1` instead of theirs. */
hostHome?: string;
entries: {
name: string;
path: string;