Add Groups to Administration
A group is a shared address and mailbox and the people who share it. To Stalwart it is an x:Account of type Group, behind the same sysAccount* permissions as a person, so it sits under Directory beside Accounts: search, a page of fifty with each group's member count, and a panel to create, edit and delete one. Membership lives on the member, not the group. Members are the users whose memberGroupIds name it, and adding or removing one is a single memberGroupIds/<group> pointer on that user's account -- true or null -- which leaves their other groups alone. Changes apply straight away rather than riding on Save, so the list is always what the server has. Nobody can add or remove themselves, the same line the account panel draws at one's own role. A group's role is Default or Custom, not a person's User or Admin, and it is what the group may do: in 0.16 a user's permissions come from their own roles only, and a group gives its members what is shared with it. Only roles the viewer could grant are offered. Delete takes the members out first and then deletes the group, the order a domain's keys go before the domain, because the registry keeps anything another object names. A role that cannot change the members' accounts is not offered a delete it could only half finish. The mock's groups had a person's roles, accepted a memberGroupIds filter without applying it, and answered a linked delete with the wrong shape; all three follow the source now, and it refuses nested groups and memberships of things that are not groups. Nothing about groups has been run against a live server yet: production has none, and every operation is a write. KNOWN-ISSUES says what was read from source. Thirty-five new strings, two of them plurals, in all nine catalogues.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import type { DirectoryGroup } from "@/lib/adminGroups";
|
||||
import type { DirectoryContext } from "../directoryContext";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
members: [
|
||||
{ id: "me", name: "demo", emailAddress: "[email protected]", description: "Demo User" },
|
||||
{ id: "u2", name: "ada", emailAddress: "[email protected]", description: "Ada Lovelace" },
|
||||
],
|
||||
setMembership: vi.fn(async () => {}),
|
||||
destroyGroup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/adminGroups", async (original) => ({
|
||||
...(await original<typeof import("@/lib/adminGroups")>()),
|
||||
listMembers: vi.fn(async () => ({ members: api.members, total: api.members.length })),
|
||||
searchUsers: vi.fn(async () => []),
|
||||
setMembership: api.setMembership,
|
||||
destroyGroup: api.destroyGroup,
|
||||
}));
|
||||
|
||||
const { GroupSheet } = await import("../GroupSheet");
|
||||
|
||||
const group: DirectoryGroup = { id: "g1", "@type": "Group", name: "support", domainId: "d1", emailAddress: "[email protected]", description: "Support", roles: { "@type": "Default" }, aliases: {} };
|
||||
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: new Map(), groups: new Map(), self: { ids: new Set(["me"]), address: "[email protected]" } };
|
||||
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
|
||||
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.includes(label));
|
||||
|
||||
/** The group panel's guards: what a role may change, and what nobody may change for themselves. */
|
||||
describe("the group sheet", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(<GroupSheet group={group} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||
});
|
||||
await act(async () => {});
|
||||
};
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
api.setMembership.mockClear();
|
||||
api.destroyGroup.mockClear();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("lists the members, and will not take the viewer out of a group themselves", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"]);
|
||||
await render();
|
||||
expect(host.querySelectorAll(".admin-members li")).toHaveLength(2);
|
||||
expect(button(host, "Remove [email protected] from the group")?.disabled).toBe(true);
|
||||
const ada = button(host, "Remove [email protected] from the group")!;
|
||||
expect(ada.disabled).toBe(false);
|
||||
await act(async () => ada.click());
|
||||
expect(api.setMembership).toHaveBeenCalledWith(["u2"], "g1", false);
|
||||
});
|
||||
|
||||
it("offers no changes to a role that can only read", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery"]);
|
||||
await render();
|
||||
expect(host.textContent).toContain("Your role lets you view groups but not change them.");
|
||||
expect(button(host, "Remove [email protected] from the group")).toBeUndefined();
|
||||
expect(host.querySelector(".admin-add-member")).toBeNull();
|
||||
expect(button(host, "Save changes")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("will not start a delete it could only half finish", async () => {
|
||||
// Deleting takes the members out first, which is an update to each of them.
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountDestroy"]);
|
||||
await render();
|
||||
expect(button(host, "Delete group…")?.disabled).toBe(true);
|
||||
expect(host.querySelector(".admin-danger")?.textContent).toContain("your role can't change their accounts");
|
||||
});
|
||||
|
||||
it("deletes with every member taken out, once the address is typed", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysAccountDestroy"]);
|
||||
await render();
|
||||
await act(async () => button(host, "Delete group…")!.click());
|
||||
const input = document.querySelector<HTMLInputElement>("#admin-group-delete-confirm")!;
|
||||
const confirm = [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent === "Delete group")!;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
set.call(input, "[email protected]");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(confirm.disabled).toBe(false);
|
||||
await act(async () => confirm.click());
|
||||
expect(api.destroyGroup).toHaveBeenCalledWith("g1", ["me", "u2"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user