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:
@@ -85,6 +85,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
||||
const [role, setRole] = useState(roleKey(account?.roles));
|
||||
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
|
||||
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
|
||||
const [tenantId, setTenantId] = useState(account?.memberTenantId ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -132,7 +133,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
||||
setError(t("An account needs an address."));
|
||||
return;
|
||||
}
|
||||
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
|
||||
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota), memberTenantId: tenantId || null });
|
||||
toast.success(t("Created {address}", { address }));
|
||||
onCreated(id);
|
||||
return;
|
||||
@@ -140,6 +141,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
|
||||
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
|
||||
if ((account.memberTenantId ?? "") !== tenantId) patch.memberTenantId = tenantId || null;
|
||||
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
|
||||
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
|
||||
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
||||
@@ -245,6 +247,18 @@ 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) && (
|
||||
<>
|
||||
<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>}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>{t("Storage")}</h3>
|
||||
{!creating && (
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Avatar, Empty, Spinner } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
|
||||
import { AccountSheet } from "./AccountSheet";
|
||||
import { listTenantNames } from "@/lib/adminTenants";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -38,6 +39,7 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
|
||||
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
|
||||
const [tenants, setTenants] = useState<Array<{ id: string; name: string }> | null>(null);
|
||||
|
||||
// Typing is not a query per keystroke.
|
||||
useEffect(() => {
|
||||
@@ -74,6 +76,7 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||||
void listGroups().then((list) => setGroups(new Map(list.map((g) => [g.id, g]))), () => setGroups(new Map()));
|
||||
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) void listTenantNames().then(setTenants, () => setTenants(null));
|
||||
}, [perms, reload]);
|
||||
|
||||
// An account opened by address that is not on the page being shown.
|
||||
@@ -103,9 +106,10 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||||
roles,
|
||||
groups,
|
||||
tenants,
|
||||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||||
};
|
||||
}, [page, serverDomains, roles, groups, session]);
|
||||
}, [page, serverDomains, roles, groups, tenants, session]);
|
||||
|
||||
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
|
||||
const close = () => navigate("/admin/accounts");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, LayoutDashboard, List, ShieldCheck, User, UsersRound } from "lucide-react";
|
||||
import { Building2, Globe, LayoutDashboard, List, ShieldCheck, User, UsersRound } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
@@ -10,6 +10,7 @@ export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
|
||||
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
|
||||
tenants: { group: "Access", label: "Tenants", icon: <Building2 size={20} /> },
|
||||
roles: { group: "Access", label: "Roles", icon: <ShieldCheck size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { GroupsAdmin } from "./GroupsAdmin";
|
||||
import { ListsAdmin } from "./ListsAdmin";
|
||||
import { RolesAdmin } from "./RolesAdmin";
|
||||
import { TenantsAdmin } from "./TenantsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
@@ -15,6 +16,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
groups: (id) => <GroupsAdmin selectedId={id} />,
|
||||
lists: (id) => <ListsAdmin selectedId={id} />,
|
||||
tenants: (id) => <TenantsAdmin selectedId={id} />,
|
||||
roles: (id) => <RolesAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Globe, Plus, Trash2, X } from "lucide-react";
|
||||
import { can, canGrantRole, type RoleDef } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError } from "@/lib/adminDirectory";
|
||||
import { describeLinked, DomainError } from "@/lib/adminDomains";
|
||||
import {
|
||||
countTenantMembers,
|
||||
createTenant,
|
||||
destroyTenant,
|
||||
drawableLogo,
|
||||
quotasPatch,
|
||||
setDomainTenant,
|
||||
tenantDomains,
|
||||
updateTenant,
|
||||
TENANT_MEMBERS,
|
||||
TENANT_QUOTAS,
|
||||
type DirectoryTenant,
|
||||
type TenantMemberKind,
|
||||
type TenantQuota,
|
||||
type TenantRoles,
|
||||
} from "@/lib/adminTenants";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { proxiedImageUrl } from "@/lib/html";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
interface Props {
|
||||
/** Null to create one. */
|
||||
tenant: DirectoryTenant | null;
|
||||
roles: ReadonlyMap<string, RoleDef> | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/** The label for each quota, and for each kind of thing a tenant holds. */
|
||||
function quotaLabel(q: TenantQuota): string {
|
||||
switch (q) {
|
||||
case "maxAccounts": return t("Accounts");
|
||||
case "maxGroups": return t("Groups");
|
||||
case "maxMailingLists": return t("Mailing lists");
|
||||
case "maxDomains": return t("Domains");
|
||||
case "maxRoles": return t("Roles");
|
||||
case "maxDkimKeys": return t("DKIM keys");
|
||||
case "maxDiskQuota": return t("Storage in GB");
|
||||
}
|
||||
}
|
||||
|
||||
const roleKey = (roles: TenantRoles | undefined) => (!roles || roles["@type"] === "Default" ? "Default" : `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`);
|
||||
const rolesFromKey = (key: string): TenantRoles =>
|
||||
key.startsWith("custom:") ? { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) } : { "@type": "Default" };
|
||||
|
||||
/** A quota as the field shows it: GB for disk space, a whole number for the rest, empty for no limit. */
|
||||
const fieldOf = (q: TenantQuota, v: number | undefined) => (v == null ? "" : q === "maxDiskQuota" ? String(Math.round((v / GIB) * 10) / 10) : String(v));
|
||||
const valueOf = (q: TenantQuota, s: string): number | null => {
|
||||
const n = Number(s.replace(",", "."));
|
||||
if (!s.trim() || !Number.isFinite(n) || n < 0) return null;
|
||||
return q === "maxDiskQuota" ? Math.round(n * GIB) : Math.floor(n);
|
||||
};
|
||||
|
||||
/**
|
||||
* One tenant, opened beside the list.
|
||||
*
|
||||
* Name, logo, role and quotas save together. What is in the tenant is shown
|
||||
* rather than stored on it: counts of each kind, read with a `memberTenantId`
|
||||
* filter, and its domains, which are added and taken out on the spot because
|
||||
* each is a change to the domain.
|
||||
*/
|
||||
export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||
const perms = usePermissions();
|
||||
const creating = tenant === null;
|
||||
const editable = creating ? can(perms, "Tenant", "Create") : can(perms, "Tenant", "Update");
|
||||
|
||||
const [name, setName] = useState(tenant?.name ?? "");
|
||||
const [logo, setLogo] = useState(tenant?.logo ?? "");
|
||||
const [role, setRole] = useState(roleKey(tenant?.roles));
|
||||
const [quotas, setQuotas] = useState<Record<TenantQuota, string>>(() => Object.fromEntries(TENANT_QUOTAS.map((q) => [q, fieldOf(q, tenant?.quotas?.[q])])) as Record<TenantQuota, string>);
|
||||
const [counts, setCounts] = useState<Partial<Record<TenantMemberKind, number>> | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenant) return;
|
||||
let cancelled = false;
|
||||
void countTenantMembers(tenant.id).then((c) => !cancelled && setCounts(c));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tenant, revision]);
|
||||
|
||||
const roleOptions = useMemo(() => {
|
||||
const options = [{ value: "Default", label: t("Default tenant roles") }];
|
||||
for (const r of roles?.values() ?? []) {
|
||||
if (canGrantRole(perms, r.id, roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
|
||||
}
|
||||
if (!options.some((o) => o.value === role)) options.push({ value: role, label: t("Custom role") });
|
||||
return options;
|
||||
}, [perms, roles, role]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const values = Object.fromEntries(TENANT_QUOTAS.map((q) => [q, valueOf(q, quotas[q])])) as Record<TenantQuota, number | null>;
|
||||
if (!tenant) {
|
||||
if (!name.trim()) {
|
||||
setError(t("A tenant needs a name."));
|
||||
return;
|
||||
}
|
||||
const set = Object.fromEntries(Object.entries(values).filter(([, v]) => v != null)) as Record<string, number>;
|
||||
const id = await createTenant({ name, logo: logo.trim() || null, roles: rolesFromKey(role), quotas: set });
|
||||
toast.success(t("Created {name}", { name: name.trim() }));
|
||||
onCreated(id);
|
||||
return;
|
||||
}
|
||||
const patch: Record<string, unknown> = { ...quotasPatch(tenant.quotas, values) };
|
||||
if (tenant.name !== name.trim()) patch.name = name.trim();
|
||||
if ((tenant.logo ?? "") !== logo.trim()) patch.logo = logo.trim() || null;
|
||||
if (roleKey(tenant.roles) !== role) patch.roles = rolesFromKey(role);
|
||||
if (!Object.keys(patch).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await updateTenant(tenant.id, patch);
|
||||
toast.success(t("Saved {name}", { name: name.trim() }));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "tenant"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const drawable = drawableLogo(logo.trim());
|
||||
const logoSrc = drawable?.startsWith("data:") ? drawable : drawable ? proxiedImageUrl(drawable) : null;
|
||||
const held = counts ? Object.values(counts).reduce((a, b) => a + (b ?? 0), 0) : null;
|
||||
const countsComplete = counts !== null && TENANT_MEMBERS.every((m) => typeof counts[m.key] === "number");
|
||||
|
||||
return (
|
||||
<aside className="admin-sheet" aria-label={creating ? t("New tenant") : tenant.name}>
|
||||
<div className="admin-sheet-head">
|
||||
{logoSrc ? <img className="admin-tenant-logo" src={logoSrc} alt="" /> : null}
|
||||
<div className="grow">
|
||||
<h2 className="truncate">{creating ? t("New tenant") : tenant.name}</h2>
|
||||
{tenant && <div className="hint">{t("{used} used", { used: formatSize(tenant.usedDiskQuota ?? 0) })}</div>}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-sheet-body">
|
||||
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view tenants but not change them.")}</p>}
|
||||
|
||||
<h3>{t("Profile")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-tenant-name">{t("Name")}</label>
|
||||
<input id="admin-tenant-name" className="input" value={name} disabled={!editable} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
|
||||
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
|
||||
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
|
||||
</div>
|
||||
|
||||
{!creating && (
|
||||
<>
|
||||
<h3>{t("What it holds")}</h3>
|
||||
{counts === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<dl className="admin-kv">
|
||||
{TENANT_MEMBERS.map((m) => {
|
||||
const limit = tenant.quotas?.[m.quota];
|
||||
const n = counts[m.key];
|
||||
return (
|
||||
<div key={m.key} style={{ display: "contents" }}>
|
||||
<dt>{quotaLabel(m.quota)}</dt>
|
||||
<dd>{n == null ? "—" : limit != null ? t("{n} of {limit}", { n, limit }) : n}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<dt>{t("Storage")}</dt>
|
||||
<dd>{tenant.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(tenant.usedDiskQuota ?? 0), total: formatSize(tenant.quotas.maxDiskQuota) }) : formatSize(tenant.usedDiskQuota ?? 0)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<h3>{t("Domains")}</h3>
|
||||
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>{t("Limits")}</h3>
|
||||
<div className="admin-quota-grid">
|
||||
{TENANT_QUOTAS.map((q) => (
|
||||
<div key={q} className="field">
|
||||
<label htmlFor={`admin-tenant-${q}`}>{quotaLabel(q)}</label>
|
||||
<input id={`admin-tenant-${q}`} className="input" inputMode="decimal" value={quotas[q]} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuotas({ ...quotas, [q]: e.target.value })} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
|
||||
|
||||
<h3>{t("Role")}</h3>
|
||||
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
|
||||
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<p className="hint">{t("The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.")}</p>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{!creating && can(perms, "Tenant", "Destroy") && (
|
||||
<DeleteTenant
|
||||
tenant={tenant}
|
||||
blocked={
|
||||
!countsComplete
|
||||
? t("Checking what is still in this tenant…")
|
||||
: held
|
||||
? t("It still holds accounts, domains or other things. Move them out first.")
|
||||
: null
|
||||
}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="admin-sheet-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>
|
||||
{creating ? t("Create tenant") : t("Save changes")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTenant; canChange: boolean; onChanged: () => void }) {
|
||||
const [state, setState] = useState<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> } | null>(null);
|
||||
const [pick, setPick] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
tenantDomains(tenant.id).then(
|
||||
(s) => {
|
||||
if (cancelled) return;
|
||||
setState(s);
|
||||
setPick(s.unassigned[0]?.id ?? "");
|
||||
},
|
||||
(err) => {
|
||||
if (cancelled) return;
|
||||
setState({ inTenant: [], unassigned: [] });
|
||||
setError(describeDirectoryError(err, "domain"));
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tenant, revision]);
|
||||
|
||||
const move = async (domain: { id: string; name: string }, into: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
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);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "domain"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return <Spinner />;
|
||||
return (
|
||||
<div>
|
||||
{state.inTenant.length ? (
|
||||
<ul className="admin-members">
|
||||
{state.inTenant.map((d) => (
|
||||
<li key={d.id}>
|
||||
<Globe size={16} aria-hidden="true" />
|
||||
<span className="grow truncate notranslate" translate="no">{d.name}</span>
|
||||
{canChange && (
|
||||
<button className="icon-btn sm" aria-label={t("Take {domain} out of the tenant", { domain: d.name })} disabled={busy} onClick={() => void move(d, false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="hint" style={{ marginTop: 0 }}>{t("No domains in this tenant yet")}</p>
|
||||
)}
|
||||
{canChange && state.unassigned.length > 0 && (
|
||||
<div className="row mt-8">
|
||||
<select className="input grow" aria-label={t("Domain to add")} value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
{state.unassigned.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-sm" disabled={busy || !pick} onClick={() => { const d = state.unassigned.find((x) => x.id === pick); if (d) void move(d, true); }}>
|
||||
<Plus size={14} /> {t("Add")}
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTenant({ tenant, blocked, onDeleted }: { tenant: DirectoryTenant; blocked: string | null; onDeleted: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<h3>{t("Delete")}</h3>
|
||||
<div className="admin-danger">
|
||||
<p>{blocked ?? t("An empty tenant can be deleted.")}</p>
|
||||
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||
<Trash2 size={14} /> {t("Delete tenant…")}
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("Delete {name}?", { name: tenant.name })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={busy || typed.trim() !== tenant.name}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await destroyTenant(tenant.id);
|
||||
toast.success(t("Deleted {name}", { name: tenant.name }));
|
||||
setOpen(false);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
|
||||
? t("Still holds {things}. Move them out first.", { things: describeLinked(err.linked) })
|
||||
: describeDirectoryError(err, "tenant"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Delete tenant")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>{t("It can't be undone.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-tenant-delete-confirm">{t("Type {name} to confirm", { name: tenant.name })}</label>
|
||||
<input id="admin-tenant-delete-confirm" className="input" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Building2, ChevronLeft, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { can, type RoleDef } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError, listRoles } from "@/lib/adminDirectory";
|
||||
import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/lib/adminTenants";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { proxiedImageUrl } from "@/lib/html";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import { TenantSheet } from "./TenantSheet";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Tenants: separate organisations on one server, each with its own people,
|
||||
* domains and limits.
|
||||
*
|
||||
* Shown to whoever may read them, whatever the edition says -- the edition is a
|
||||
* licence claim, not an authority -- but on a server that does not report
|
||||
* Enterprise the page says what that means for the people inside one.
|
||||
*/
|
||||
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const perms = usePermissions();
|
||||
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
|
||||
const [text, setText] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState(0);
|
||||
const [page, setPage] = useState<{ tenants: DirectoryTenant[]; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||
const [loose, setLoose] = useState<DirectoryTenant | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setQuery(text);
|
||||
setPosition(0);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const q = await queryTenants({ text: query, position, limit: PAGE_SIZE });
|
||||
const tenants = await getTenants(q.ids);
|
||||
if (!cancelled) setPage({ tenants, total: q.total });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPage({ tenants: [], total: 0 });
|
||||
setError(describeDirectoryError(err, "tenant"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, position, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||||
}, [perms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedId === "new" || page?.tenants.some((x) => x.id === selectedId)) {
|
||||
setLoose(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getTenants([selectedId]).then(
|
||||
([x]) => { if (!cancelled) setLoose(x ?? null); },
|
||||
() => { if (!cancelled) setLoose(null); },
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId, page]);
|
||||
|
||||
const selected = selectedId && selectedId !== "new" ? (page?.tenants.find((x) => x.id === selectedId) ?? loose) : null;
|
||||
const close = () => navigate("/admin/tenants");
|
||||
const changed = () => setReload((n) => n + 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Tenants")}</h1>
|
||||
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
|
||||
</div>
|
||||
{can(perms, "Tenant", "Create") && (
|
||||
<button className="btn btn-primary" onClick={() => navigate("/admin/tenants/new")}>
|
||||
<Plus size={16} /> {t("New tenant")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{edition !== "enterprise" && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search tenants")} aria-label={t("Search tenants")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{page === null ? (
|
||||
<Spinner />
|
||||
) : page.tenants.length === 0 ? (
|
||||
!error && <Empty icon={<Building2 size={32} />} title={query ? t("No tenants match") : t("No tenants yet")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("Tenant")}</th>
|
||||
<th>{t("Storage")}</th>
|
||||
<th className="hide-mobile">{t("Account limit")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.tenants.map((x) => {
|
||||
const logo = drawableLogo(x.logo);
|
||||
const src = logo?.startsWith("data:") ? logo : logo ? proxiedImageUrl(logo) : null;
|
||||
return (
|
||||
<tr
|
||||
key={x.id}
|
||||
className={x.id === selectedId ? "selected" : ""}
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/admin/tenants/${x.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/admin/tenants/${x.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={t("Open {name}", { name: x.name })}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-who">
|
||||
{src ? <img className="admin-tenant-logo sm" src={src} alt="" /> : <Building2 size={20} className="muted" aria-hidden="true" />}
|
||||
<div className="admin-who-name truncate">{x.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{x.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(x.usedDiskQuota ?? 0), total: formatSize(x.quotas.maxDiskQuota) }) : t("{used} · no limit", { used: formatSize(x.usedDiskQuota ?? 0) })}
|
||||
</td>
|
||||
<td className="hide-mobile muted" style={{ fontVariantNumeric: "tabular-nums" }}>{x.quotas?.maxAccounts ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{page.total <= PAGE_SIZE && position === 0 ? (
|
||||
<p className="hint admin-count">{plural(page.total, { one: "{n} tenant", other: "{n} tenants" })}</p>
|
||||
) : (
|
||||
<div className="admin-pager">
|
||||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.tenants.length, total: page.total })}</span>
|
||||
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
|
||||
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.tenants.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedId === "new" || selected) && (
|
||||
<TenantSheet
|
||||
key={selectedId}
|
||||
tenant={selectedId === "new" ? null : selected!}
|
||||
roles={roles}
|
||||
onClose={close}
|
||||
onChanged={changed}
|
||||
onCreated={(id) => {
|
||||
changed();
|
||||
navigate(`/admin/tenants/${id}`);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
changed();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ export interface DirectoryContext {
|
||||
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
|
||||
roles: Map<string, RoleDef> | null;
|
||||
groups: Map<string, DirectoryAccount>;
|
||||
/** Tenants an account can be put in; absent when the viewer cannot read them, which hides the choice. */
|
||||
tenants?: Array<{ id: string; name: string }> | null;
|
||||
/** Registry ids and addresses that are the signed-in account itself. */
|
||||
self: { ids: Set<string>; address: string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user