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
@@ -0,0 +1,108 @@
/**
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* @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";
const me = vi.hoisted(() => ({
account: vi.fn(),
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { me } };
});
const confirm = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
import { AccountSettings } from "../AccountSettings";
const account = {
mode: "server" as const,
user: {
id: 2,
username: "mia",
role: "developer" as const,
disabled: false,
displayName: "Mia Tran",
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
workspaces: 3,
sessions: 12,
logins: 2,
lastLoginAt: null,
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
me.signOutOthers.mockResolvedValue({ ended: 1 });
confirm.mockReset().mockResolvedValue(true);
});
describe("AccountSettings", () => {
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
expect(card.textContent).toContain("Mia Tran");
expect(card.textContent).toContain("@mia");
expect(card.textContent).toContain("Developer");
expect(card.textContent).toContain("12");
});
it("saves only a changed field", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
});
it("refuses two different new passwords", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
const button = screen.getByRole("button", { name: "Change password" });
expect(button.hasAttribute("disabled")).toBe(true);
expect(me.changePassword).not.toHaveBeenCalled();
});
it("changes the password with the current one", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
await ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
);
});
it("asks before signing the other devices out", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
});
});