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 && (
+20 -2
View File
@@ -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
View File
@@ -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();
+30 -13
View File
@@ -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>