Files
ihasmail-inbuxa/web/src/views/admin/AdminDashboard.tsx
T
jcoffey-dev 8abf3a96aa A tenant's administrator can turn legacy mail apps off for the organization
INBUXA's tenant switch (legacy-protocols LP-9 to LP-18) in the
administration. Each tenant's sheet gains "Legacy mail apps": whether IMAP,
POP3, ManageSieve and sending from mail apps are on or off on the tenant's
domains, and the switch.

Nobody turns it off by accident. "Turn off legacy protocols…" first shows
who would notice -- every account in the tenant that signed in with a
legacy mail app in the last 30 days, with the protocols and when (LP-15) --
and the statement of what it means, "for everyone in {tenant}" (LP-16),
then asks for the phrase "turn off legacy mail", matched exactly (LP-17).
Turning it back on is one click; the server refuses while it has legacy
protocols off for everyone, and its words are shown. A tenant's switch
closes no port, so the statement names none.

It needs the domain permissions the server checks for the switch; with
read-only access the state shows and the buttons don't. On a server that
isn't INBUXA, or is older, the section isn't there.

The dashboard says so while legacy mail is off for the signed-in
administrator's organization (LP-18), from the same session flag as
Settings › Security's line.

Every new string in all nine languages, the register each catalog uses, and
the count in each language's plural forms.
2026-09-21 13:54:24 -07:00

162 lines
8.2 KiB
TypeScript

import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react";
import { legacyProtocolsOff } from "@/jmap/client";
import { useAppName } from "@/lib/brand";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard";
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n";
import { Empty } from "@/ui/misc";
import { useSession } from "@/store/session";
import { usePermissions } from "./usePermissions";
/** Loading, a number, refused by the server (the card goes), or failed (the card says so). */
type Loaded<T> = { state: "loading" } | { state: "ok"; value: T } | { state: "refused" } | { state: "error" };
const LOADING = { state: "loading" } as const;
async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
try {
return { state: "ok", value: await work };
} catch (err) {
return isRefused(err) ? { state: "refused" } : { state: "error" };
}
}
/**
* Administration's landing page: a card for each number the role may read.
*
* Which cards appear is `dashboardCards`, from the permissions Stalwart
* reported; what they count is whatever Stalwart answers for this account,
* which for a tenant administrator is their tenancy. A card whose feed the
* server refuses anyway -- the metric history on a Community server -- is left
* off rather than shown broken, and one that could not be loaded says so.
*/
export function AdminDashboard() {
const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId));
const app = useAppName();
const cards = dashboardCards(perms);
const sections = adminSections(perms);
const [reload, setReload] = useState(0);
const [users, setUsers] = useState<Loaded<number>>(LOADING);
const [domains, setDomains] = useState<Loaded<number>>(LOADING);
const [pending, setPending] = useState<Loaded<number>>(LOADING);
const [messages, setMessages] = useState<Loaded<MessageStats>>(LOADING);
const key = cards.join(",");
useEffect(() => {
let canceled = false;
const into = <T,>(set: (v: Loaded<T>) => void, work: () => Promise<T>) => {
set(LOADING);
void settle(work()).then((v) => !canceled && set(v));
};
if (cards.includes("users")) into(setUsers, () => countObjects("Account"));
if (cards.includes("domains")) into(setDomains, () => countObjects("Domain"));
if (cards.includes("pending")) into(setPending, () => countObjects("QueuedMessage"));
if (cards.includes("received")) into(setMessages, async () => summarizeMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
return () => {
canceled = true;
};
// `key` is the card list's contents; the array itself is new every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, reload]);
const number = new Intl.NumberFormat(resolvedLocale());
const count = (v: Loaded<number>) => (v.state === "ok" ? number.format(v.value) : undefined);
const stats = messages.state === "ok" ? messages.value : null;
const unrecorded = stats !== null && !stats.recorded;
const every: Array<{ id: DashboardCard; loaded: Loaded<unknown>; node: ReactNode }> = [
{ id: "users", loaded: users, node: <Card icon={<Users size={20} />} label={t("Users")} value={count(users)} caption={t("User accounts")} loaded={users} href={sections.includes("accounts") ? "/admin/accounts" : undefined} /> },
{ id: "domains", loaded: domains, node: <Card icon={<Globe size={20} />} label={t("Domains")} value={count(domains)} caption={t("Mail domains")} loaded={domains} href={sections.includes("domains") ? "/admin/domains" : undefined} /> },
{ id: "pending", loaded: pending, node: <Card icon={<Hourglass size={20} />} label={t("Pending")} value={count(pending)} caption={t("Waiting in the delivery queue")} loaded={pending} /> },
{
id: "memory",
loaded: messages,
node: (
<Card
icon={<MemoryStick size={20} />}
label={t("Server memory")}
value={stats?.memory ? formatSize(stats.memory.bytes) : undefined}
caption={stats?.memory ? t("As of {time}", { time: formatDayMonthTime(new Date(stats.memory.at)) }) : unrecorded ? t("Not recorded on this server") : t("Last 24 hours")}
loaded={messages}
/>
),
},
{ id: "received", loaded: messages, node: <Card icon={<ArrowDownToLine size={20} />} label={t("Received")} value={stats?.recorded ? number.format(stats.received) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> },
{ id: "sent", loaded: messages, node: <Card icon={<ArrowUpFromLine size={20} />} label={t("Sent")} value={stats?.recorded ? number.format(stats.sent) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> },
];
const shown = every.filter((c) => cards.includes(c.id) && c.loaded.state !== "refused");
const columns = balancedColumns(shown.length);
return (
// The column counts are set here rather than on the grid, so the heading --
// and its Refresh button -- end where the cards do.
<div className="admin-dashboard" style={{ "--cols-wide": columns.wide, "--cols-mid": columns.mid } as React.CSSProperties}>
<div className="admin-head">
<div className="grow">
<h1>{t("Dashboard")}</h1>
<p className="lead">{t("The numbers your role can see, as the server reports them.")}</p>
</div>
<button className="icon-btn" aria-label={t("Refresh")} title={t("Refresh")} onClick={() => setReload((n) => n + 1)}>
<RefreshCw size={18} />
</button>
</div>
{legacyOff && (
// INBUXA legacy-protocols LP-18: while it's off for the organization
<p className="admin-notice">
<ShieldCheck size={16} />
<span>{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}</span>
</p>
)}
{shown.length ? (
<div className="admin-cards-wrap">
<div className="admin-cards">
{shown.map((c) => <div key={c.id}>{c.node}</div>)}
</div>
</div>
) : (
<Empty icon={<LayoutDashboard size={32} />} title={t("Nothing to show")} />
)}
{/* The line between the two interfaces, said where someone looking for
more numbers will be: this is a glance, and operating the server is
Stalwart's own administration. The link is the operator's to give. */}
<p className="hint admin-dashboard-note">
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")}
{adminUrl && (
<>
{" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
</a>
</>
)}
</p>
</div>
);
}
function Card({ icon, label, value, caption, loaded, href }: { icon: ReactNode; label: string; value: string | undefined; caption: string; loaded: Loaded<unknown>; href?: string }) {
const body = (
<>
<div className="admin-card-top">
<span className="admin-card-icon" aria-hidden="true">{icon}</span>
<span className="admin-card-label">{label}</span>
</div>
<div className="admin-card-value" aria-busy={loaded.state === "loading"}>
{loaded.state === "loading" ? <span className="admin-card-placeholder" /> : (value ?? "—")}
</div>
<div className="hint">{loaded.state === "error" ? t("Could not be loaded") : caption}</div>
</>
);
return href ? (
<Link href={href} className="admin-card link">{body}</Link>
) : (
<div className="admin-card">{body}</div>
);
}