An account whose Stalwart role manages accounts now finds Administration in the account menu. It lists, searches, creates and edits accounts -- display name, other addresses, role, storage limit -- sets a new password, and deletes, each offered only when the role holds the matching permission. The server keeps the permissions list from GET /api/account, which it already called for the edition and threw the rest away. Everything else is JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap proxy, so nothing new is stored and Stalwart decides every call. Stalwart checks a grant against the caller's permissions but not a password change or a delete, so an account that outranks the viewer is shown read-only. Your own password is changed in Settings, which re-seals the session; changing it here would strand it. The mock server gains a directory behind the same permission names, with MOCK_ROLE choosing admin, tenant-admin, helpdesk or user. 68 new strings, translated in all nine catalogues; strings falling back to English stay at 16.
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { Router } from "wouter";
|
|
import { memoryLocation } from "wouter/memory-location";
|
|
import { useSession } from "@/store/session";
|
|
import type { JmapSession } from "@/jmap/types";
|
|
import type { DirectoryAccount } from "@/lib/adminDirectory";
|
|
import { AccountSheet } from "../AccountSheet";
|
|
import type { DirectoryContext } from "../directoryContext";
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
|
|
|
function signIn(permissions: string[], username = "[email protected]") {
|
|
useSession.setState({
|
|
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
|
});
|
|
}
|
|
|
|
const account = (over: Partial<DirectoryAccount>): DirectoryAccount => ({
|
|
id: "u1",
|
|
"@type": "User",
|
|
name: "ada",
|
|
domainId: "d1",
|
|
emailAddress: "[email protected]",
|
|
description: "Ada Lovelace",
|
|
roles: { "@type": "User" },
|
|
credentials: { "0": { "@type": "Password", secret: "[********]" } },
|
|
...over,
|
|
});
|
|
|
|
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(["self"]), address: "[email protected]" } };
|
|
|
|
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
|
|
|
|
/**
|
|
* The guards that stand in for checks Stalwart does not make. A store test
|
|
* cannot see these: they are what the sheet renders, and what it leaves out.
|
|
*/
|
|
describe("the account sheet", () => {
|
|
let host: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
const render = async (a: DirectoryAccount) => {
|
|
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
|
|
await act(async () => {
|
|
root.render(
|
|
<Router hook={hook}>
|
|
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
|
|
</Router>,
|
|
);
|
|
});
|
|
};
|
|
|
|
beforeEach(() => {
|
|
host = document.createElement("div");
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
host.remove();
|
|
});
|
|
|
|
it("shows an account that outranks the viewer read-only, password included", async () => {
|
|
signIn(HELPDESK);
|
|
await render(account({ roles: { "@type": "Admin" } }));
|
|
expect(host.textContent).toContain("permissions yours doesn't");
|
|
expect(button(host, "Set a new password")?.disabled).toBe(true);
|
|
expect((host.querySelector("#admin-description") as HTMLInputElement).disabled).toBe(true);
|
|
expect(host.textContent).not.toContain("Save changes");
|
|
});
|
|
|
|
it("lets the same viewer edit an ordinary account, but not delete it", async () => {
|
|
signIn(HELPDESK);
|
|
await render(account({}));
|
|
expect(button(host, "Set a new password")?.disabled).toBe(false);
|
|
expect(host.textContent).toContain("Save changes");
|
|
expect(host.textContent).not.toContain("Delete account");
|
|
});
|
|
|
|
it("sends your own password to Settings, and keeps your role and account out of reach", async () => {
|
|
signIn([...HELPDESK, "sysAccountDestroy"]);
|
|
await render(account({ id: "self", emailAddress: "[email protected]" }));
|
|
expect(host.textContent).toContain("Change your own password in");
|
|
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
|
|
expect(button(host, "Set a new password")).toBeUndefined();
|
|
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
|
|
expect(button(host, "Delete account")?.disabled).toBe(true);
|
|
});
|
|
});
|