Add Domains to Administration

A role that can read domains now finds a Domains section beside Accounts:
list and search with each domain's account count and whether its DNS, DKIM
and certificate are managed automatically; add a domain; edit its
description, other names, catch-all address and plus addressing; copy its
DNS records one at a time or as a zone file; see its DKIM keys and their
stage; and remove it once no accounts use it.

The records come from the zone file Stalwart computes per domain. A long
DKIM record, which the BIND serialiser splits into quoted chunks, is joined
back into the single value a DNS provider's form wants.

Removing a domain takes its DKIM keys first, in the same request, because the
server will not remove a domain its keys still name. Removal is not offered
while accounts use the domain, or when the role cannot remove the keys.

The Administration nav is now built from the sections the role can read, and
the menu appears when there is at least one. The mock gains domains, DKIM
keys and zone files.

61 new strings, translated in all nine catalogues; strings falling back to
English stay at 16.
This commit is contained in:
2026-09-13 15:39:16 -07:00
parent d279fe8f90
commit 1dafb4bc79
23 changed files with 1783 additions and 30 deletions
+28 -12
View File
@@ -1,32 +1,48 @@
import type { ReactNode } from "react";
import { Link, Redirect, useLocation } from "wouter";
import { ArrowLeft, User } from "lucide-react";
import { hasAdministration } from "@/lib/adminAccess";
import { ArrowLeft, Globe, User } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { AccountsAdmin } from "./AccountsAdmin";
import { DomainsAdmin } from "./DomainsAdmin";
import { usePermissions } from "./usePermissions";
const SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode; render: (id?: string) => ReactNode }> = {
accounts: { group: "Directory", label: "Accounts", icon: <User size={18} />, render: (id) => <AccountsAdmin selectedId={id} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={18} />, render: (id) => <DomainsAdmin selectedId={id} /> },
};
/**
* Administration: what the signed-in account's Stalwart role lets it manage.
*
* Laid out like Settings, because it is the same kind of place -- a list of
* sections and the one that is open -- and on a phone it behaves the same way,
* the list first and a section on its own. Accounts is the only section so
* far; the nav is written as a list so the next one is an entry, not a rework.
* the list first and a section on its own. Only the sections the role can read
* are listed; a section typed into the address bar that it cannot read opens
* the first one it can.
*/
export function AdminView({ section, id }: { section?: string; id?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const allowed = adminSections(usePermissions());
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
// would refuse every call anyway; this spares the page of refusals.
if (!hasAdministration(perms)) return <Redirect to="/mail" />;
if (!allowed.length) return <Redirect to="/mail" />;
const current = allowed.find((s) => s === section) ?? allowed[0]!;
const groups = [...new Set(allowed.map((s) => SECTIONS[s].group))];
return (
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label={t("Administration")}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Directory")}</span></div>
<Link href="/admin/accounts" className={`nav-item ${!section || section === "accounts" ? "active" : ""}`}>
<User size={18} />
<span className="nav-label">{t("Accounts")}</span>
</Link>
{groups.map((group) => (
<div key={group}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t(group)}</span></div>
{allowed.filter((s) => SECTIONS[s].group === group).map((s) => (
<Link key={s} href={`/admin/${s}`} className={`nav-item ${current === s ? "active" : ""}`}>
{SECTIONS[s].icon}
<span className="nav-label">{t(SECTIONS[s].label)}</span>
</Link>
))}
</div>
))}
</nav>
<div className="settings-content admin-content">
{section && (
@@ -34,7 +50,7 @@ export function AdminView({ section, id }: { section?: string; id?: string }) {
<ArrowLeft size={16} /> {t("Administration")}
</button>
)}
<AccountsAdmin selectedId={id} />
{SECTIONS[current].render(section === current ? id : undefined)}
</div>
</div>
);
+417
View File
@@ -0,0 +1,417 @@
import { useEffect, useMemo, useState } from "react";
import { Copy, Globe, Plus, Trash2, X } from "lucide-react";
import { can } from "@/lib/adminAccess";
import { describeDirectoryError } from "@/lib/adminDirectory";
import {
createDomain,
describeLinked,
destroyDomain,
dkimAlgorithm,
DomainError,
getDomains,
listDkimKeys,
looksLikeDomain,
namesOf,
normaliseDomain,
parseZoneFile,
updateDomain,
type DirectoryDomainFull,
type DkimKey,
type Managed,
} from "@/lib/adminDomains";
import { formatFullDate } from "@/lib/format";
import { plural, t } from "@/lib/i18n";
import { Dialog } from "@/ui/dialog";
import { Spinner, Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { usePermissions } from "./usePermissions";
interface Props {
/** Null to add one. */
id: string | null;
/** How many accounts use it, when the list could count them. */
accountCount?: number;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
export function ManagedLabel({ value }: { value?: Managed }) {
const automatic = value?.["@type"] === "Automatic";
return <span className={`admin-role ${automatic ? "admin" : ""}`}>{automatic ? t("Automatic") : t("By hand")}</span>;
}
const STAGE_LABEL: Record<string, string> = { active: "Signing", pending: "Published, not signing yet", retiring: "Retiring", retired: "Retired" };
const copy = (text: string, done: string) =>
void navigator.clipboard?.writeText(text).then(() => toast.success(done), () => toast.error(t("Could not copy")));
/**
* One domain, beside the list: what it is called, where its mail goes, and the
* records the world needs to see before any of that works.
*
* The DNS records are the part people come here for. Stalwart computes them
* per domain -- MX, SPF, DKIM, DMARC, the service records, MTA-STS -- so they
* are shown one per row, each with its own copy button, because a DNS
* provider's form takes one record at a time.
*/
export function DomainSheet({ id, accountCount, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = id === null;
const editable = creating ? can(perms, "Domain", "Create") : can(perms, "Domain", "Update");
const [domain, setDomain] = useState<DirectoryDomainFull | null>(null);
const [keys, setKeys] = useState<DkimKey[] | null>(null);
const [provider, setProvider] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [aliases, setAliases] = useState<string[]>([]);
const [catchAll, setCatchAll] = useState("");
const [plus, setPlus] = useState<"Enabled" | "Disabled" | "Custom">("Enabled");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
if (!id) return;
let cancelled = false;
void (async () => {
try {
const [d] = await getDomains([id], { zoneFile: true });
if (cancelled) return;
if (!d) {
setLoadError(t("This domain no longer exists. Someone may have removed it."));
return;
}
setDomain(d);
setDescription(d.description ?? "");
setAliases(Object.keys(d.aliases ?? {}));
setCatchAll(d.catchAllAddress ?? "");
setPlus(d.subAddressing?.["@type"] ?? "Enabled");
if (can(perms, "DkimSignature", "Query") && can(perms, "DkimSignature", "Get")) {
void listDkimKeys(id).then((k) => { if (!cancelled) setKeys(k); }, () => { if (!cancelled) setKeys(null); });
}
const serverId = d.dnsManagement?.["@type"] === "Automatic" ? d.dnsManagement.dnsServerId : undefined;
if (serverId && can(perms, "DnsServer", "Get")) {
void namesOf("DnsServer", [serverId]).then((n) => { if (!cancelled) setProvider(n.get(serverId) ?? null); }, () => {});
}
} catch (err) {
if (!cancelled) setLoadError(describeDirectoryError(err));
}
})();
return () => {
cancelled = true;
};
}, [id, perms, revision]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const records = useMemo(() => (domain?.dnsZoneFile ? parseZoneFile(domain.dnsZoneFile) : []), [domain]);
const save = async () => {
setBusy(true);
setError(null);
try {
if (creating) {
if (!looksLikeDomain(name)) {
setError(t("That doesn't look like a domain name, such as example.com."));
return;
}
const newId = await createDomain({ name, description });
toast.success(t("Added {name}. Its DNS records are ready to copy.", { name: normaliseDomain(name) }));
onCreated(newId);
return;
}
if (!domain) return;
const patch: Record<string, unknown> = {};
if ((domain.description ?? "") !== description) patch.description = description.trim() || null;
const nextAliases = [...new Set(aliases.map(normaliseDomain).filter(Boolean))];
if (JSON.stringify(Object.keys(domain.aliases ?? {}).sort()) !== JSON.stringify([...nextAliases].sort())) {
patch.aliases = Object.fromEntries(nextAliases.map((a) => [a, true]));
}
if ((domain.catchAllAddress ?? "") !== catchAll.trim()) patch.catchAllAddress = catchAll.trim() || null;
if ((domain.subAddressing?.["@type"] ?? "Enabled") !== plus && plus !== "Custom") patch.subAddressing = { "@type": plus };
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateDomain(domain.id, patch);
toast.success(t("Saved {name}", { name: domain.name }));
setRevision((n) => n + 1);
onChanged();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
};
const title = creating ? t("Add domain") : (domain?.name ?? "");
return (
<aside className="admin-sheet" aria-label={title}>
<div className="admin-sheet-head">
<span className="avatar" style={{ background: "var(--accent-soft)", color: "var(--accent-soft-fg)" }} aria-hidden="true"><Globe size={18} /></span>
<div className="grow">
<h2 className="truncate notranslate" translate="no">{title}</h2>
{domain?.createdAt && <div className="hint truncate">{t("Added {date}", { date: formatFullDate(domain.createdAt) })}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{loadError ? (
<p className="admin-notice error" role="alert">{loadError}</p>
) : !creating && !domain ? (
<Spinner />
) : (
<>
{domain?.isEnabled === false && <p className="admin-notice warn"><span>{t("This domain is disabled on the server.")}</span></p>}
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view domains but not change them.")}</p>}
{creating && (
<>
<h3>{t("Domain")}</h3>
<div className="field">
<label htmlFor="admin-domain-name">{t("Name")}</label>
<input id="admin-domain-name" className="input notranslate" translate="no" placeholder="example.com" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value)} />
<span className="hint">{t("New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.")}</span>
</div>
</>
)}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-domain-description">{t("Description")}</label>
<input id="admin-domain-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
</div>
{!creating && domain && (
<>
<h3>{t("Other names")}</h3>
<AliasList aliases={aliases} setAliases={setAliases} editable={editable} />
<h3>{t("Delivery")}</h3>
<div className="field">
<label htmlFor="admin-domain-catchall">{t("Catch-all address")}</label>
<input id="admin-domain-catchall" className="input notranslate" translate="no" value={catchAll} disabled={!editable} placeholder={t("None")} spellCheck={false} onChange={(e) => setCatchAll(e.target.value)} />
<span className="hint">{t("Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.")}</span>
</div>
<Switch
checked={plus !== "Disabled"}
disabled={!editable || plus === "Custom"}
onChange={(on) => setPlus(on ? "Enabled" : "Disabled")}
label={t("Plus addressing")}
hint={plus === "Custom" ? t("Set by a custom rule on the server.") : t("Mail to name+anything@ is delivered to name@.")}
/>
<h3>{t("DNS records")}</h3>
{domain.dnsManagement?.["@type"] === "Automatic" ? (
<p className="hint" style={{ marginTop: 0 }}>
{provider ? t("Published automatically through {provider}.", { provider }) : t("Published automatically by the server.")}
</p>
) : (
<p className="hint" style={{ marginTop: 0 }}>{t("Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.")}</p>
)}
{records.length ? (
<>
<div className="admin-dns">
{records.map((r, i) => (
<div className="admin-dns-row" key={i}>
<span className="admin-dns-type">{r.type || "?"}</span>
<div className="grow">
<div className="admin-dns-name notranslate" translate="no">{r.name}</div>
<code className="admin-dns-value notranslate" translate="no">{r.value}</code>
</div>
<button className="icon-btn xs" aria-label={t("Copy {type} record for {name}", { type: r.type, name: r.name })} title={t("Copy value")} onClick={() => copy(r.value, t("Copied"))}>
<Copy size={14} />
</button>
</div>
))}
</div>
<button className="btn btn-sm mt-8" onClick={() => copy(domain.dnsZoneFile ?? "", t("Copied the zone file"))}>
<Copy size={14} /> {t("Copy all as a zone file")}
</button>
</>
) : (
<p className="hint">{t("The server returned no records for this domain.")}</p>
)}
{keys && (
<>
<h3>{t("DKIM keys")}</h3>
<p className="hint" style={{ marginTop: 0 }}>
{domain.dkimManagement?.["@type"] === "Automatic" ? t("The server creates and rotates these keys itself.") : t("These keys are managed by hand on the server.")}
</p>
{keys.length ? (
<table className="sessions-table">
<tbody>
{keys.map((k) => (
<tr key={k.id}>
<td><code className="notranslate" translate="no">{k.selector}</code><div className="hint">{dkimAlgorithm(k["@type"])}</div></td>
<td><span className={`admin-role ${k.stage === "active" ? "admin" : ""}`}>{t(STAGE_LABEL[k.stage ?? "active"] ?? "Signing")}</span></td>
<td className="hint">{k.createdAt ? formatFullDate(k.createdAt) : ""}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="admin-notice warn"><span>{t("No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.")}</span></p>
)}
</>
)}
<h3>{t("Managed by the server")}</h3>
<dl className="admin-kv">
<dt>{t("DNS records")}</dt><dd><ManagedLabel value={domain.dnsManagement} /></dd>
<dt>{t("DKIM keys")}</dt><dd><ManagedLabel value={domain.dkimManagement} /></dd>
<dt>{t("Certificate")}</dt><dd><ManagedLabel value={domain.certificateManagement} /></dd>
</dl>
</>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && domain && can(perms, "Domain", "Destroy") && (
<RemoveDomain domain={domain} accountCount={accountCount} keys={keys} canRemoveKeys={can(perms, "DkimSignature", "Destroy")} onDeleted={onDeleted} />
)}
</>
)}
</div>
{editable && !loadError && (creating || domain) && (
<div className="admin-sheet-foot">
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={busy || (creating && !name.trim())} onClick={() => void save()}>
{creating ? t("Add domain") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function AliasList({ aliases, setAliases, editable }: { aliases: string[]; setAliases: (a: string[]) => void; editable: boolean }) {
const [value, setValue] = useState("");
const add = () => {
const name = normaliseDomain(value);
if (!looksLikeDomain(name) || aliases.includes(name)) return;
setAliases([...aliases, name]);
setValue("");
};
return (
<div>
<div className="row wrap gap-4">
{aliases.length ? (
aliases.map((a) => (
<span key={a} className="chip notranslate" translate="no">
{a}
{editable && (
<button className="chip-x" aria-label={t("Remove {address}", { address: a })} onClick={() => setAliases(aliases.filter((x) => x !== a))}>
<X size={12} />
</button>
)}
</span>
))
) : (
<span className="hint">{t("None")}</span>
)}
</div>
{editable && (
<div className="row mt-8">
<input className="input grow notranslate" translate="no" aria-label={t("Another name for this domain")} placeholder="example.net" value={value} spellCheck={false} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
<button className="btn btn-sm" onClick={add} disabled={!looksLikeDomain(value)}>
<Plus size={14} /> {t("Add")}
</button>
</div>
)}
{editable && <p className="hint">{t("Mail to the same address at any of these names reaches the same account. Changes apply when you save.")}</p>}
</div>
);
}
function RemoveDomain({ domain, accountCount, keys, canRemoveKeys, onDeleted }: {
domain: DirectoryDomainFull;
accountCount?: number;
keys: DkimKey[] | null;
canRemoveKeys: boolean;
onDeleted: () => void;
}) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const keyCount = keys?.length ?? 0;
const blocked = accountCount
? plural(accountCount, { one: "{n} account uses this domain. Move or delete it first.", other: "{n} accounts use this domain. Move or delete them first." })
: keyCount && !canRemoveKeys
? t("Its DKIM keys have to be removed first, and your role can't remove them.")
: null;
return (
<>
<h3>{t("Remove")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("The server stops accepting mail for this domain.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Remove domain…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Remove {name}?", { name: domain.name })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || normaliseDomain(typed) !== domain.name}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyDomain(domain.id, canRemoveKeys ? (keys ?? []).map((k) => k.id) : []);
toast.success(t("Removed {name}", { name: domain.name }));
setOpen(false);
onDeleted();
} catch (err) {
setError(
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
? t("The server kept the domain: it is still used by {things}.", { things: describeLinked(err.linked) })
: describeDirectoryError(err),
);
} finally {
setBusy(false);
}
}}
>
{t("Remove domain")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>
{keyCount
? plural(keyCount, { one: "The server stops accepting mail for this domain, and its {n} DKIM key is deleted. This can't be undone.", other: "The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone." })
: t("The server stops accepting mail for this domain. This can't be undone.")}
</p>
<div className="field">
<label htmlFor="admin-domain-confirm">{t("Type {address} to confirm", { address: domain.name })}</label>
<input id="admin-domain-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Globe, Plus, Search } from "lucide-react";
import { can } from "@/lib/adminAccess";
import { describeDirectoryError } from "@/lib/adminDirectory";
import { countAccounts, getDomains, namesOf, queryDomains, type DirectoryDomainFull } from "@/lib/adminDomains";
import { plural, t } from "@/lib/i18n";
import { Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { DomainSheet, ManagedLabel } from "./DomainSheet";
const PAGE_SIZE = 50;
export function DomainsAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const [page, setPage] = useState<{ domains: DirectoryDomainFull[]; total: number } | null>(null);
const [counts, setCounts] = useState<Map<string, number>>(new Map());
const [tenants, setTenants] = useState<Map<string, string>>(new Map());
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
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 queryDomains({ text: query, position, limit: PAGE_SIZE });
const domains = await getDomains(q.ids);
if (cancelled) return;
setPage({ domains, total: q.total });
// Both are extras on top of the list, and each needs a permission of its own.
if (can(perms, "Account", "Query")) void countAccounts(domains.map((d) => d.id)).then((c) => { if (!cancelled) setCounts(c); });
const tenantIds = [...new Set(domains.map((d) => d.memberTenantId).filter((x): x is string => Boolean(x)))];
if (tenantIds.length && can(perms, "Tenant", "Get")) void namesOf("Tenant", tenantIds).then((n) => { if (!cancelled) setTenants(n); }, () => {});
} catch (err) {
if (!cancelled) {
setPage({ domains: [], total: 0 });
setError(describeDirectoryError(err));
}
}
})();
return () => {
cancelled = true;
};
}, [query, position, reload, perms]);
const close = () => navigate("/admin/domains");
const showTenants = tenants.size > 0;
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Domains")}</h1>
<p className="lead">{t("Where your addresses live, and the DNS records that let mail arrive and be trusted.")}</p>
</div>
{can(perms, "Domain", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/domains/new")}>
<Plus size={16} /> {t("Add domain")}
</button>
)}
</div>
<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 domains")} aria-label={t("Search domains")} />
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{page === null ? (
<Spinner />
) : page.domains.length === 0 ? (
!error && <Empty icon={<Globe size={32} />} title={query ? t("No domains match") : t("No domains yet")} />
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Domain")}</th>
<th>{t("Accounts")}</th>
<th>{t("DNS records")}</th>
<th className="hide-mobile">{t("DKIM")}</th>
<th className="hide-mobile">{t("Certificate")}</th>
{showTenants && <th className="hide-mobile">{t("Tenant")}</th>}
</tr>
</thead>
<tbody>
{page.domains.map((d) => (
<tr
key={d.id}
className={d.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/domains/${d.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/domains/${d.id}`);
}
}}
aria-label={t("Open {address}", { address: d.name })}
>
<td>
<div className="admin-who-name notranslate" translate="no">
{d.name}
{d.isEnabled === false && <span className="badge muted">{t("Disabled")}</span>}
</div>
{Object.keys(d.aliases ?? {}).length > 0 && (
<div className="hint truncate notranslate" translate="no">{t("also {names}", { names: Object.keys(d.aliases ?? {}).join(", ") })}</div>
)}
</td>
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{counts.has(d.id) ? counts.get(d.id) : "—"}</td>
<td><ManagedLabel value={d.dnsManagement} /></td>
<td className="hide-mobile"><ManagedLabel value={d.dkimManagement} /></td>
<td className="hide-mobile"><ManagedLabel value={d.certificateManagement} /></td>
{showTenants && <td className="hide-mobile muted">{d.memberTenantId ? (tenants.get(d.memberTenantId) ?? "—") : "—"}</td>}
</tr>
))}
</tbody>
</table>
</div>
{page.total <= PAGE_SIZE && position === 0 ? (
<p className="hint admin-count">{plural(page.total, { one: "{n} domain", other: "{n} domains" })}</p>
) : (
<div className="admin-pager">
<span className="hint">{t("{from}{to} of {total}", { from: position + 1, to: position + page.domains.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.domains.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
</div>
)}
</>
)}
{selectedId && (
<DomainSheet
key={selectedId}
id={selectedId === "new" ? null : selectedId}
accountCount={selectedId === "new" ? undefined : counts.get(selectedId)}
onClose={close}
onChanged={() => setReload((n) => n + 1)}
onCreated={(id) => {
setReload((n) => n + 1);
navigate(`/admin/domains/${id}`);
}}
onDeleted={() => {
setReload((n) => n + 1);
close();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,85 @@
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";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const domain = {
id: "d1",
name: "example.com",
aliases: {},
subAddressing: { "@type": "Custom" },
dnsManagement: { "@type": "Manual" },
dkimManagement: { "@type": "Automatic" },
certificateManagement: { "@type": "Manual" },
dnsZoneFile: 'example.com. IN MX 10 mail.example.com.\nexample.com. IN TXT "v=spf1 mx -all"\n',
};
vi.mock("@/lib/adminDomains", async (original) => ({
...(await original<typeof import("@/lib/adminDomains")>()),
getDomains: vi.fn(async () => [domain]),
listDkimKeys: vi.fn(async () => [{ id: "k1", "@type": "Dkim1Ed25519Sha256", selector: "v1-ed25519", stage: "active" }]),
}));
const { DomainSheet } = await import("../DomainSheet");
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
/**
* What decides whether a domain can be removed is not the button but what
* still uses it, and some of that is the domain's own keys.
*/
describe("the domain sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (accountCount: number | undefined) => {
await act(async () => {
root.render(<DomainSheet id="d1" accountCount={accountCount} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("lists the records one per row, unquoted", async () => {
signIn(["sysDomainGet", "sysDomainQuery"]);
await render(0);
expect(host.querySelectorAll(".admin-dns-row").length).toBe(2);
expect(host.textContent).toContain("v=spf1 mx -all");
expect(host.textContent).not.toContain('"v=spf1');
});
it("will not offer removal while accounts use the domain", async () => {
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainDestroy", "sysDkimSignatureQuery", "sysDkimSignatureGet", "sysDkimSignatureDestroy"]);
await render(3);
expect(host.textContent).toContain("3 accounts use this domain");
expect(button(host, "Remove domain")?.disabled).toBe(true);
});
it("will not offer removal when the keys that must go first cannot be removed", async () => {
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainDestroy", "sysDkimSignatureQuery", "sysDkimSignatureGet"]);
await render(0);
expect(host.textContent).toContain("your role can't remove them");
expect(button(host, "Remove domain")?.disabled).toBe(true);
});
it("leaves a plus-addressing rule set on the server alone", async () => {
signIn(["sysDomainGet", "sysDomainQuery", "sysDomainUpdate"]);
await render(0);
expect(host.textContent).toContain("Set by a custom rule on the server.");
expect((host.querySelector('button[role="switch"]') as HTMLButtonElement).disabled).toBe(true);
});
});