Add Tenants to Administration, and let an account be put in one

A tenant is a separate organisation on one server: its own people,
domains and limits, and an administrator who manages only what is in it.
It gets a section under Access, gated by sysTenantQuery and sysTenantGet,
with a notice on a server that does not report Enterprise, where anyone
inside a tenant is held to an ordinary user's permissions.

The panel edits the tenant's name, logo, role and limits. The logo is an
https address, drawn through the image proxy the strict image policy
requires, or an image data URL. Limits change one quotas/<name> pointer
each, so the four ihasmail does not offer keep their values, and an empty
field is no limit. The role is the most anyone inside can be allowed.

Stalwart keeps no list on a tenant -- each account, group, domain, list and
role names its own -- so what a tenant holds is counted with memberTenantId
queries and shown against its limits. Domains are added and taken out from
the tenant's panel, one memberTenantId change each; only a domain in no
tenant can be added, and its accounts stay where they are. Delete is offered
once every count reads zero.

A tenant does nothing until someone administers it, so the account panel
gains a Tenant choice for an administrator who can read tenants: an
Administrator inside a tenant administers that tenant. Nobody moves their
own account.

The mock has a tenant holding a domain and an administrator, a spare domain
to assign, memberTenantId filters on every query, and Stalwart's rule that
only an administrator outside every tenant may move things into one. A test
of taking a domain back out found that the mock's pointer handling dropped a
top-level null instead of storing it, so nothing had ever been cleared that
way; it stores null now, as the server reads it back.

Nothing about tenants has been written on a live server: production has
none. KNOWN-ISSUES says what was read from source.

Thirty-nine new strings and one plural, in all nine catalogues.
This commit is contained in:
2026-09-15 09:28:39 -07:00
parent 7e46ddeb7d
commit fd104a1f34
29 changed files with 1467 additions and 25 deletions
@@ -0,0 +1,94 @@
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 { DirectoryTenant } from "@/lib/adminTenants";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({
counts: { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 } as Record<string, number>,
updateTenant: vi.fn(async () => {}),
setDomainTenant: vi.fn(async () => {}),
}));
vi.mock("@/lib/adminTenants", async (original) => ({
...(await original<typeof import("@/lib/adminTenants")>()),
countTenantMembers: vi.fn(async () => api.counts),
tenantDomains: vi.fn(async () => ({ inTenant: [{ id: "d3", name: "old-brand.example" }], unassigned: [{ id: "d4", name: "spare.example" }] })),
updateTenant: api.updateTenant,
setDomainTenant: api.setDomainTenant,
}));
const { TenantSheet } = await import("../TenantSheet");
const tenant: DirectoryTenant = { id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, quotas: { maxAccounts: 25, maxDomains: 2, maxOauthClients: 3 }, usedDiskQuota: 0 };
const ALL = ["sysTenantGet", "sysTenantQuery", "sysTenantUpdate", "sysTenantDestroy", "sysDomainUpdate"];
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?.trim() === label || b.textContent?.includes(label));
const type = async (el: HTMLInputElement, value: string) => {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
});
};
describe("the tenant sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
await act(async () => {
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.updateTenant.mockClear();
api.setDomainTenant.mockClear();
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 };
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows what it holds against its limits, and will not delete while it holds anything", async () => {
signIn(ALL);
await render();
expect(host.querySelector(".admin-kv")?.textContent).toContain("1 of 25");
expect(button(host, "Delete tenant…")?.disabled).toBe(true);
});
it("offers the delete once it is empty", async () => {
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0 };
signIn(ALL);
await render();
expect(button(host, "Delete tenant…")?.disabled).toBe(false);
});
it("saves a changed limit as one pointer, and an emptied one as no limit", async () => {
signIn(ALL);
await render();
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxAccounts")!, "30");
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxDomains")!, "");
await act(async () => button(host, "Save changes")!.click());
expect(api.updateTenant).toHaveBeenCalledWith("t1", { "quotas/maxAccounts": 30, "quotas/maxDomains": null });
});
it("moves a domain in, and offers no domain moves without the permission to change domains", async () => {
signIn(ALL);
await render();
await act(async () => button(host, "Add")!.click());
expect(api.setDomainTenant).toHaveBeenCalledWith("d4", "t1");
await act(async () => root.unmount());
root = createRoot(host);
signIn(["sysTenantGet", "sysTenantQuery"]);
await render();
expect(host.querySelector('select[aria-label="Domain to add"]')).toBeNull();
expect(button(host, "Take old-brand.example out of the tenant")).toBeUndefined();
});
});