Follow what the live server does with tenants and domains

A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:

- Something in a tenant has to be on a domain in that tenant (a list in a
  tenant on an unassigned domain is invalidForeignKey), while something in
  no tenant may be on a tenant's domain. The account panel's tenant choice
  offered every tenant; it offers only the domain's now, and a new account
  starts in the tenant of the domain it is made on. The domain list reads
  memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
  the tenant from being deleted. They are counted with the rest, so Delete
  is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
  on it, stranding them. The panel asks first and refuses while any are
  there.

The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.

The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
This commit is contained in:
2026-09-15 09:37:00 -07:00
parent ce5eb04c2d
commit 40df0f658b
21 changed files with 211 additions and 48 deletions
+20 -4
View File
@@ -93,6 +93,11 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
}, [ctx.domains, domainId]);
// A new account starts in the tenant of the domain it is being made on.
useEffect(() => {
if (creating) setTenantId(ctx.domains.find((d) => d.id === domainId)?.memberTenantId ?? "");
}, [creating, domainId, ctx.domains]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
@@ -102,6 +107,13 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
}, [onClose]);
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
/*
* Stalwart refuses an account in a tenant on a domain outside it (live,
* 2026-09-15: invalidForeignKey naming the domain), and allows one in no
* tenant on a tenant's domain. So the only tenant to offer is the domain's.
*/
const domainTenant = ctx.domains.find((d) => d.id === (account?.domainId ?? domainId))?.memberTenantId ?? null;
const tenantName = (id: string) => ctx.tenants?.find((x) => x.id === id)?.name ?? id;
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
const roleOptions = useMemo(() => {
@@ -247,15 +259,19 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
</p>
{ctx.tenants && (ctx.tenants.length > 0 || tenantId) && (
{ctx.tenants && (domainTenant || tenantId) && (
<>
<h3>{t("Tenant")}</h3>
<select className="input admin-wide" aria-label={t("Tenant")} value={tenantId} disabled={!editable || self} onChange={(e) => setTenantId(e.target.value)}>
<option value="">{t("No tenant")}</option>
{ctx.tenants.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
{tenantId && !ctx.tenants.some((x) => x.id === tenantId) && <option value={tenantId}>{tenantId}</option>}
{domainTenant && <option value={domainTenant}>{tenantName(domainTenant)}</option>}
{tenantId && tenantId !== domainTenant && <option value={tenantId}>{tenantName(tenantId)}</option>}
</select>
<p className="hint">{self ? t("You can't move your own account into a tenant.") : t("An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}</p>
<p className="hint">
{self
? t("You can't move your own account into a tenant.")
: t("An account can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}
</p>
</>
)}
+13 -2
View File
@@ -10,6 +10,7 @@ import {
drawableLogo,
quotasPatch,
setDomainTenant,
tenantAccountsOnDomain,
tenantDomains,
updateTenant,
TENANT_MEMBERS,
@@ -21,7 +22,7 @@ import {
} from "@/lib/adminTenants";
import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/html";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
import { Dialog } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
@@ -281,6 +282,16 @@ function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTena
setBusy(true);
setError(null);
try {
if (!into) {
const stranded = await tenantAccountsOnDomain(tenant.id, domain.id);
if (stranded > 0) {
setError(plural(stranded, {
one: "{n} account in this tenant is still on {domain}. Move it or delete it before taking the domain out.",
other: "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.",
}, { domain: domain.name }));
return;
}
}
await setDomainTenant(domain.id, into ? tenant.id : null);
toast.success(into ? t("Added {domain} to {tenant}", { domain: domain.name, tenant: tenant.name }) : t("Took {domain} out of {tenant}", { domain: domain.name, tenant: tenant.name }));
setRevision((n) => n + 1);
@@ -323,7 +334,7 @@ function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTena
</div>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
<p className="hint">{t("Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.")}</p>
<p className="hint">{t("Only domains in no tenant can be added, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.")}</p>
</div>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
</div>
</div>
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}</p>
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature.")}</p>
</div>
);
}
@@ -92,3 +92,45 @@ describe("the account sheet", () => {
expect(button(host, "Delete account")?.disabled).toBe(true);
});
});
/**
* An account in a tenant has to be on a domain in that tenant -- the live
* server refuses anything else -- so the only tenant offered is the domain's.
*/
describe("an account's tenant", () => {
let host: HTMLDivElement;
let root: Root;
const tenantCtx: DirectoryContext = {
...ctx,
domains: [{ id: "d1", name: "example.com", memberTenantId: null }, { id: "d3", name: "acme.example", memberTenantId: "t1" }],
tenants: [{ id: "t1", name: "Acme Corp" }, { id: "t2", name: "Globex" }],
};
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={tenantCtx} 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("offers only the tenant its domain is in", async () => {
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
await render(account({ domainId: "d3", memberTenantId: "t1", emailAddress: "[email protected]" }));
const options = [...host.querySelectorAll<HTMLOptionElement>('select[aria-label="Tenant"] option')].map((o) => o.textContent);
expect(options).toEqual(["No tenant", "Acme Corp"]);
});
it("offers no choice at all on a domain in no tenant", async () => {
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
await render(account({ domainId: "d1" }));
expect(host.querySelector('select[aria-label="Tenant"]')).toBeNull();
});
});
@@ -8,7 +8,8 @@ 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>,
counts: { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0, dkimKeys: 2 } as Record<string, number>,
onDomain: 0,
updateTenant: vi.fn(async () => {}),
setDomainTenant: vi.fn(async () => {}),
}));
@@ -18,6 +19,7 @@ vi.mock("@/lib/adminTenants", async (original) => ({
tenantDomains: vi.fn(async () => ({ inTenant: [{ id: "d3", name: "old-brand.example" }], unassigned: [{ id: "d4", name: "spare.example" }] })),
updateTenant: api.updateTenant,
setDomainTenant: api.setDomainTenant,
tenantAccountsOnDomain: vi.fn(async () => api.onDomain),
}));
const { TenantSheet } = await import("../TenantSheet");
@@ -49,7 +51,8 @@ describe("the tenant sheet", () => {
root = createRoot(host);
api.updateTenant.mockClear();
api.setDomainTenant.mockClear();
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 };
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0, dkimKeys: 2 };
api.onDomain = 0;
});
afterEach(async () => {
await act(async () => root.unmount());
@@ -64,7 +67,7 @@ describe("the tenant sheet", () => {
});
it("offers the delete once it is empty", async () => {
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0 };
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0, dkimKeys: 0 };
signIn(ALL);
await render();
expect(button(host, "Delete tenant…")?.disabled).toBe(false);
@@ -92,3 +95,30 @@ describe("the tenant sheet", () => {
expect(button(host, "Take old-brand.example out of the tenant")).toBeUndefined();
});
});
describe("taking a domain out of a tenant", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.setDomainTenant.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("is refused while the tenant still has accounts on it, which Stalwart would strand", async () => {
api.onDomain = 2;
signIn(ALL);
await act(async () => {
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
await act(async () => button(host, "Take old-brand.example out of the tenant")!.click());
expect(api.setDomainTenant).not.toHaveBeenCalled();
expect(host.querySelector(".admin-notice.error")?.textContent).toContain("2 accounts in this tenant are still on old-brand.example");
});
});