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
@@ -0,0 +1,57 @@
/**
* @file clipboard.test.ts
* @description `writeClipboard` / `copyText`: the async Clipboard API when the
* origin has one, else the hidden-textarea `execCommand("copy")` — the only
* path on `http://<lan-ip>`, where a terminal selection must still reach the
* user's clipboard — and the focus goes back to where it was.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyText, writeClipboard } from "../clipboard";
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const setClipboard = (value: unknown) =>
Object.defineProperty(navigator, "clipboard", { value, configurable: true });
afterEach(() => {
if (original) Object.defineProperty(navigator, "clipboard", original);
else delete (navigator as { clipboard?: unknown }).clipboard;
vi.restoreAllMocks();
});
describe("writeClipboard", () => {
it("uses the Clipboard API when the origin has one", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setClipboard({ writeText });
expect(await writeClipboard("hello")).toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
});
it("falls back to execCommand on a non-secure origin, and gives the focus back", async () => {
setClipboard(undefined);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
let copied = "";
const exec = vi.fn((cmd: string) => {
copied = document.querySelector("textarea")?.value ?? "";
return cmd === "copy";
});
Object.defineProperty(document, "execCommand", { value: exec, configurable: true });
expect(await writeClipboard("from tmux")).toBe(true);
expect(exec).toHaveBeenCalledWith("copy");
expect(copied).toBe("from tmux");
expect(document.activeElement).toBe(input);
expect(document.querySelector("textarea")).toBeNull();
input.remove();
});
it("copyText reports a failed copy", async () => {
setClipboard(undefined);
Object.defineProperty(document, "execCommand", {
value: vi.fn(() => false),
configurable: true,
});
expect(await copyText("x", true)).toBe(false);
});
});
+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;
+20 -11
View File
@@ -4,7 +4,9 @@
* Uses the async Clipboard API when it exists and falls back to a hidden
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
* a toast, so a copy is never silent.
* a toast, so a copy is never silent. `writeClipboard(text)` is the same two
* paths with no toast, for a copy the user did not click (a terminal
* selection); it hands the focus back to wherever it was.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -12,6 +14,8 @@ import i18n from "../i18n";
import { toast } from "./toast";
function fallbackCopy(text: string): boolean {
// Selecting the textarea moves the focus: give it back (a terminal keeps typing).
const was = document.activeElement as HTMLElement | null;
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
@@ -25,25 +29,30 @@ function fallbackCopy(text: string): boolean {
return false;
} finally {
ta.remove();
was?.focus?.({ preventScroll: true });
}
}
/** Copy `text` silently: the Clipboard API, else the textarea path. */
export async function writeClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to the textarea path */
}
return fallbackCopy(text);
}
/**
* Copy text to the clipboard and toast the result.
* @param quiet Skip the success toast (the caller shows its own feedback).
* @returns Whether the copy succeeded.
*/
export async function copyText(text: string, quiet = false): Promise<boolean> {
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch {
/* fall through to the textarea path */
}
if (!ok) ok = fallbackCopy(text);
const ok = await writeClipboard(text);
if (!ok) toast.error(i18n.t("common:copyFailed"));
else if (!quiet) toast.success(i18n.t("common:copied"));
return ok;
+20
View File
@@ -35,6 +35,26 @@ export const SETTINGS_INDEX: {
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
adminOnly?: boolean;
}[] = [
{
section: "account",
titleKey: "account.profile",
descKey: "account.profileDescription",
keywords: ["profile", "email", "display name", "hồ sơ", "tên hiển thị", "tài khoản"],
to: "#account-profile",
},
{
section: "account",
titleKey: "account.password",
descKey: "account.passwordDescription",
keywords: ["password", "mật khẩu", "đổi mật khẩu"],
to: "#account-password",
},
{
section: "account",
titleKey: "account.sessionsTitle",
keywords: ["sign out", "logout", "devices", "đăng xuất", "thiết bị"],
to: "#account-sessions",
},
{
section: "appearance",
titleKey: "language.title",
+2
View File
@@ -2666,6 +2666,8 @@ export interface Lane {
owner_id?: number | null;
/** The owner's username (`users.username`); null with no owner. */
owner_name?: string | null;
/** The owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */