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>
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
/**
|
|
* @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());
|
|
});
|
|
});
|