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 && (
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* clears), and one page per section. A `#anchor` in the URL scrolls to the
|
||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* In server mode a signed-in account gets **Account** first (its own
|
||||
* information, display name/email, password and sign-ins —
|
||||
* `AccountSettings`); local mode has no account and no such section.
|
||||
* An unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). Small pages share one: System is the old
|
||||
* About (`about` redirects), Git holds git logins and worktree suggestions
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
Router,
|
||||
Search,
|
||||
Server,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
@@ -57,6 +61,7 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiProxyConfig } from "./AiProxyConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AccountSettings } from "../components/settings/AccountSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
@@ -73,7 +78,7 @@ import {
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { useAuth, usePermissions } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
@@ -237,12 +242,25 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const allSections: Section[] = [
|
||||
...(signedIn
|
||||
? [
|
||||
{
|
||||
id: "account",
|
||||
group: "general" as const,
|
||||
Icon: UserRound,
|
||||
...s("account"),
|
||||
render: () => <AccountSettings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "appearance",
|
||||
group: "general",
|
||||
|
||||
+90
-195
@@ -4,9 +4,12 @@
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
* password again (the server refuses without it); an analyst only reads.
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,20 +22,15 @@ import {
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MessagesSquare,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Shield,
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
@@ -40,14 +38,13 @@ import { useSpin } from "../hooks/useSpin";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
||||
|
||||
export function Users() {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
@@ -57,7 +54,8 @@ export function Users() {
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
@@ -114,11 +112,7 @@ export function Users() {
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDialog({ kind: "add" })}
|
||||
className="btn-primary"
|
||||
>
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<UserPlus className="w-4 h-4" /> {t("add")}
|
||||
</button>
|
||||
)}
|
||||
@@ -209,11 +203,19 @@ export function Users() {
|
||||
aria-hidden
|
||||
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{u.username.slice(0, 2)}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-fg-primary">
|
||||
{u.username}
|
||||
{(u.displayName || u.username).slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">
|
||||
{u.displayName || u.username}
|
||||
{u.displayName && (
|
||||
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
|
||||
)}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
|
||||
)}
|
||||
</div>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
@@ -244,37 +246,8 @@ export function Users() {
|
||||
<UsageCell usage={u.last30d} />
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
@@ -285,6 +258,8 @@ export function Users() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||
@@ -310,9 +285,11 @@ export function Users() {
|
||||
{formatDateTime(a.at)}
|
||||
</span>
|
||||
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
||||
<span className="font-medium text-fg-primary">{a.reader}</span>
|
||||
<span className="font-medium text-fg-primary">
|
||||
{a.reader_display || a.reader}
|
||||
</span>
|
||||
<span className="mx-1.5 text-fg-muted">→</span>
|
||||
{a.owner}
|
||||
{a.owner_display || a.owner}
|
||||
</span>
|
||||
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
||||
{a.method}
|
||||
@@ -326,14 +303,25 @@ export function Users() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<AccountDialog
|
||||
dialog={dialog}
|
||||
users={users ?? []}
|
||||
onClose={() => setDialog(null)}
|
||||
{adding && (
|
||||
<AddUserDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={(message) => {
|
||||
toast.success(message);
|
||||
setDialog(null);
|
||||
setAdding(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<UserEditDialog
|
||||
user={editing}
|
||||
self={editing.id === me.id}
|
||||
users={users ?? []}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(message) => {
|
||||
toast.success(message);
|
||||
setEditing(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
@@ -342,12 +330,6 @@ export function Users() {
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_BADGE: Record<Role, string> = {
|
||||
admin: "bg-accent/15 text-accent border-accent/20",
|
||||
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||
};
|
||||
|
||||
/** A period's cost, with its tokens underneath. */
|
||||
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
const { t } = useTranslation("users");
|
||||
@@ -384,71 +366,31 @@ function RowAction({
|
||||
);
|
||||
}
|
||||
|
||||
/** One account change: its own fields, then the admin's password to confirm it. */
|
||||
function AccountDialog({
|
||||
dialog,
|
||||
users,
|
||||
/** A new account: username, password, role, then the admin's own password. */
|
||||
function AddUserDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
dialog: Dialog;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const { kind, user } = dialog;
|
||||
const name = user?.username ?? "";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "developer");
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [role, setRole] = useState<Role>("developer");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "workspace") return;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [kind]);
|
||||
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(kind === "add"
|
||||
? username.trim().length > 0 && password.length > 0
|
||||
: kind === "password"
|
||||
? password.length > 0
|
||||
: kind === "workspace"
|
||||
? laneId !== null
|
||||
: kind === "role"
|
||||
? role !== user?.role
|
||||
: true);
|
||||
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (kind === "add") {
|
||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||
onDone(t("done.created", { name: r.user.username }));
|
||||
} else if (kind === "role" && user) {
|
||||
await api.users.update(user.id, { role }, current);
|
||||
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
|
||||
} else if ((kind === "disable" || kind === "enable") && user) {
|
||||
await api.users.update(user.id, { disabled: kind === "disable" }, current);
|
||||
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
|
||||
} else if (kind === "password" && user) {
|
||||
await api.users.setPassword(user.id, password, current);
|
||||
onDone(t("done.password", { name }));
|
||||
} else if (kind === "workspace" && user && laneId !== null) {
|
||||
await api.users.assignWorkspace(user.id, laneId, current);
|
||||
onDone(t("done.workspace", { name }));
|
||||
}
|
||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||
onDone(t("done.created", { name: r.user.displayName || r.user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
@@ -461,20 +403,6 @@ function AccountDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const titles: Record<DialogKind, string> = {
|
||||
add: t("dialog.add"),
|
||||
role: t("dialog.role", { name }),
|
||||
disable: t("dialog.disable", { name }),
|
||||
enable: t("dialog.enable", { name }),
|
||||
password: t("dialog.password", { name }),
|
||||
workspace: t("dialog.workspace", { name }),
|
||||
};
|
||||
const messages: Partial<Record<DialogKind, string>> = {
|
||||
disable: t("dialog.disableBody", { name }),
|
||||
password: t("dialog.passwordBody", { name }),
|
||||
workspace: t("dialog.workspaceBody", { name }),
|
||||
};
|
||||
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
|
||||
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
@@ -486,81 +414,48 @@ function AccountDialog({
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={titles[kind]}
|
||||
message={messages[kind]}
|
||||
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
|
||||
title={t("dialog.add")}
|
||||
confirmLabel={t("add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={kind === "disable"}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
{kind === "add" && (
|
||||
<Field label={t("username")} hint={t("usernameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("username")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "password") && (
|
||||
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
aria-label={kind === "add" ? t("password") : t("newPassword")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "role") && (
|
||||
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{kind === "workspace" && (
|
||||
<Field label={t("workspace")}>
|
||||
{lanes && lanes.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
aria-label={t("workspace")}
|
||||
>
|
||||
<option value="" />
|
||||
{(lanes ?? []).map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{(l.title || l.cwd) +
|
||||
" — " +
|
||||
(l.owner_id == null
|
||||
? t("noOwner")
|
||||
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("username")} hint={t("usernameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("username")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("password")} hint={t("passwordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
aria-label={t("password")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AiProxyConfig } from "../AiProxyConfig";
|
||||
import { AiProxyConfig, nineRouterUrl } from "../AiProxyConfig";
|
||||
import { api } from "../../lib/api";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
import type { AuthState } from "../../lib/api";
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
test: vi.fn(),
|
||||
nineRouter: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
|
||||
vi.mocked(api.aiProxies.nineRouter).mockReset().mockResolvedValue({ installed: false });
|
||||
vi.mocked(api.aiProxies.test)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -83,6 +85,27 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AiProxyConfig", () => {
|
||||
it("shows Config 9router beside Add profile only when 9router is installed", async () => {
|
||||
vi.mocked(api.aiProxies.nineRouter).mockResolvedValue({ installed: true });
|
||||
render(<AiProxyConfig />);
|
||||
const link = await screen.findByRole("link", { name: /aiProxy\.config9router/ });
|
||||
// The dashboard's own host (jsdom's is localhost), not a fixed localhost.
|
||||
expect(link).toHaveAttribute("href", `http://${window.location.hostname}:20128`);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("points Config 9router at the host the dashboard was opened on", () => {
|
||||
expect(nineRouterUrl({ hostname: "10.1.13.7" })).toBe("http://10.1.13.7:20128");
|
||||
expect(nineRouterUrl({ hostname: "::1" })).toBe("http://[::1]:20128");
|
||||
});
|
||||
|
||||
it("hides Config 9router when 9router is not installed", async () => {
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Native (Claude / Codex)");
|
||||
await waitFor(() => expect(api.aiProxies.nineRouter).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("link", { name: /aiProxy\.config9router/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||
render(<AiProxyConfig />);
|
||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description The Users page: local mode and a member are sent home; an
|
||||
* analyst reads the table with no account controls; an admin adds a user only
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the dialog, and cannot change their own role or
|
||||
* disable themselves.
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -37,6 +38,7 @@ const users = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
@@ -46,6 +48,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
users,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -69,6 +74,10 @@ beforeEach(() => {
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
users.get.mockImplementation(async (id: number) => ({
|
||||
user: { id, username: "x", role: "developer", disabled: false },
|
||||
workspaces: [],
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Users page", () => {
|
||||
@@ -119,15 +128,15 @@ describe("Users page", () => {
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the dialog and keeps it open", async () => {
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.setPassword.mockRejectedValue(
|
||||
users.update.mockRejectedValue(
|
||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||
);
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
|
||||
await ue.click(screen.getAllByRole("button", { name: "Edit user" })[1]!);
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
@@ -135,18 +144,26 @@ describe("Users page", () => {
|
||||
"Your password is wrong."
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
|
||||
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" }, "wrong");
|
||||
});
|
||||
|
||||
it("never offers an admin a role change or disable on their own row", async () => {
|
||||
it("offers one Edit per row and no separate account buttons", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
|
||||
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
|
||||
expect(ownRole!.hasAttribute("disabled")).toBe(true);
|
||||
expect(ownLock!.hasAttribute("disabled")).toBe(true);
|
||||
expect(miaRole!.hasAttribute("disabled")).toBe(false);
|
||||
expect(miaLock!.hasAttribute("disabled")).toBe(false);
|
||||
expect(screen.getAllByRole("button", { name: "Edit user" })).toHaveLength(2);
|
||||
for (const name of ["Change role", "Reset password", "Give a workspace", "Disable"])
|
||||
expect(screen.queryByRole("button", { name })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the display name first, with @username beside it, and the email", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.list.mockResolvedValue({
|
||||
users: [{ ...row(2, "mia", "developer"), displayName: "Mia Tran", email: "mia@x.vn" }],
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText("Mia Tran")).toBeTruthy();
|
||||
expect(screen.getByText("@mia")).toBeTruthy();
|
||||
expect(screen.getByText("mia@x.vn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.5 (rebuild client to match)
|
||||
UI build v2.7.6 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user