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
+43 -9
View File
@@ -26,6 +26,11 @@
* their own and every profile they add is personal; an admin chooses when
* adding; an analyst only reads.
*
* **Config 9router** (beside Add profile) opens 9router's own UI in a new tab
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
* It is shown only when the server finds a `9router` binary installed.
*
* **Test** asks the endpoint itself, once as Claude Code would
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
* "Saved" and "works" are different claims, and the gap between them is only
@@ -36,7 +41,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { Plus, Pencil, Trash2, Lock, ExternalLink } from "lucide-react";
import {
api,
type AiProxy,
@@ -50,6 +55,14 @@ import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { useAuth } from "../components/AuthGate";
const NINE_ROUTER_PORT = 20128;
/** 9router's UI on the machine serving this page (it listens on every interface). */
export const nineRouterUrl = (loc: Pick<Location, "hostname"> = window.location): string => {
const host = loc.hostname.includes(":") ? `[${loc.hostname}]` : loc.hostname;
return `http://${host}:${NINE_ROUTER_PORT}`;
};
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
@@ -125,6 +138,14 @@ export function AiProxyConfig() {
void load();
}, [load]);
const [nineRouter, setNineRouter] = useState(false);
useEffect(() => {
api.aiProxies
.nineRouter()
.then((r) => setNineRouter(r.installed))
.catch(() => setNineRouter(false));
}, []);
// Any edit to the form invalidates the last verdict — see `testResult`.
useEffect(() => setTestResult(null), [form]);
@@ -238,14 +259,27 @@ export function AiProxyConfig() {
<div className="flex items-start justify-between gap-4">
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
<button
type="button"
onClick={startAdd}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("aiProxy.add")}
</button>
<div className="flex shrink-0 items-center gap-2">
{nineRouter && (
<a
href={nineRouterUrl()}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("aiProxy.config9router")}
</a>
)}
<button
type="button"
onClick={startAdd}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("aiProxy.add")}
</button>
</div>
</div>
{error && (