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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user