0c2f49876d
- 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>
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
/**
|
|
* @file UserEditDialog.test.tsx
|
|
* @description The admin's account editor: Save stays off until something
|
|
* changed AND the admin typed their own password; one PATCH carries only the
|
|
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
|
* admin editing their own row cannot touch role or state; Generate fills a
|
|
* 16-character password.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ServerUserUsage } from "../../../lib/api";
|
|
|
|
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
|
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return { ...actual, api: { users, lanes } };
|
|
});
|
|
|
|
import { UserEditDialog, generatePassword } from "../UserEditDialog";
|
|
|
|
const usage = { cost: 0, tokens: 0 };
|
|
const mia: ServerUserUsage = {
|
|
id: 2,
|
|
username: "mia",
|
|
role: "developer",
|
|
disabled: false,
|
|
displayName: null,
|
|
email: null,
|
|
createdAt: "2026-10-01T00:00:00.000Z",
|
|
workspaces: 1,
|
|
sessions: 3,
|
|
last7d: usage,
|
|
last30d: usage,
|
|
logins: 2,
|
|
lastLoginAt: null,
|
|
};
|
|
|
|
function open(user = mia, self = false) {
|
|
const onDone = vi.fn();
|
|
const onClose = vi.fn();
|
|
render(
|
|
<UserEditDialog user={user} self={self} users={[mia]} onClose={onClose} onDone={onDone} />
|
|
);
|
|
return { onDone, onClose };
|
|
}
|
|
|
|
const save = () => screen.getByRole("button", { name: "Save" });
|
|
|
|
beforeEach(() => {
|
|
users.get.mockReset().mockResolvedValue({
|
|
user: mia,
|
|
workspaces: [{ id: 5, title: "api", cwd: "/w/api" }],
|
|
});
|
|
users.update.mockReset().mockResolvedValue({ user: mia });
|
|
lanes.list.mockReset().mockResolvedValue({
|
|
lanes: [
|
|
{ id: 5, title: "api", cwd: "/w/api", owner_id: 2 },
|
|
{ id: 7, title: "web", cwd: "/w/web", owner_id: null },
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("UserEditDialog", () => {
|
|
it("needs a change and the admin's own password before Save", async () => {
|
|
const ue = userEvent.setup();
|
|
open();
|
|
await screen.findByText("api");
|
|
expect(save().hasAttribute("disabled")).toBe(true);
|
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
|
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
|
expect(save().hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
it("sends one PATCH with only what changed", async () => {
|
|
const ue = userEvent.setup();
|
|
const { onDone } = open();
|
|
await screen.findByText("api");
|
|
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
|
await ue.selectOptions(screen.getByLabelText("Role"), "analyst");
|
|
await ue.click(screen.getByRole("switch", { name: "Active" }));
|
|
await ue.click(screen.getByRole("button", { name: "Remove api" }));
|
|
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
|
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
|
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await ue.click(save());
|
|
await waitFor(() =>
|
|
expect(users.update).toHaveBeenCalledWith(
|
|
2,
|
|
{
|
|
email: "mia@x.vn",
|
|
role: "analyst",
|
|
disabled: true,
|
|
addWorkspaces: [7],
|
|
removeWorkspaces: [5],
|
|
signOut: true,
|
|
},
|
|
"boss-pass"
|
|
)
|
|
);
|
|
expect(onDone).toHaveBeenCalledWith("mia updated");
|
|
});
|
|
|
|
it("never lets an admin change their own role or disable themselves", async () => {
|
|
open({ ...mia, role: "admin" }, true);
|
|
await screen.findByText("api");
|
|
expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true);
|
|
expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("accepts a short new password: there is no minimum length", async () => {
|
|
const ue = userEvent.setup();
|
|
open();
|
|
await screen.findByText("api");
|
|
await ue.type(screen.getByLabelText("New password"), "abc");
|
|
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
expect(save().hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
it("generates a 16-character password", async () => {
|
|
expect(generatePassword()).toHaveLength(16);
|
|
const ue = userEvent.setup();
|
|
open();
|
|
await screen.findByText("api");
|
|
await ue.click(screen.getByRole("button", { name: /Generate/ }));
|
|
expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16);
|
|
});
|
|
});
|