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.
58 lines
2.8 KiB
TypeScript
58 lines
2.8 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Link, Redirect, useLocation } from "wouter";
|
|
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. 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 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 (!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")}>
|
|
{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 && (
|
|
<button className="btn btn-ghost btn-sm admin-back" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/admin")}>
|
|
<ArrowLeft size={16} /> {t("Administration")}
|
|
</button>
|
|
)}
|
|
{SECTIONS[current].render(section === current ? id : undefined)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|