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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user