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:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user