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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user