Files
ihasmail-inbuxa/web/src/views/admin/DomainsAdmin.tsx
T
jcoffey-dev 15b1838e21 Say every Administration refusal in the reader's language
Stalwart explains a refused change in English, and several of its words
reached the page as they were: "Invalid domain name" for a reserved TLD,
"Invalid email address" for a catch-all, a grant refusal, and ihasmail's own
proxy messages. Every registry error type now has its own message, and a
value one of the registry's string validators refused is recognised by the
validator's wording and explained again. A domain clash or a missing domain
is worded for a domain rather than an account.

The one exception is kept on purpose: a password policy's reason follows a
translated sentence, because the rule is the server's and dropping it would
leave no way to find out why.

The mock now refuses a reserved TLD and a catch-all without a domain the way
the live server did. KNOWN-ISSUES records the fix, and that the last two live
cases -- an administrator-set password and the outranking guard -- held.

15 new strings in all nine catalogues, 3 retired; strings falling back to
English stay at 16.
2026-09-13 17:18:15 -07:00

169 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "domain"));
}
}
})();
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>
);
}