Add Administration, starting with accounts

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.
This commit is contained in:
2026-09-13 15:11:55 -07:00
parent b0564679e6
commit 82e217155b
34 changed files with 2684 additions and 22 deletions
@@ -0,0 +1,94 @@
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);
});
});