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>
</>
)}