Point from the dashboard to Stalwart's own administration

A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.

Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.

The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.

Two new strings, in all nine catalogues.
This commit is contained in:
2026-09-15 08:16:52 -07:00
parent 0054b8a3ce
commit 4787e8bf12
21 changed files with 226 additions and 23 deletions
+2
View File
@@ -38,6 +38,8 @@ export interface JmapSession {
server?: {
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null;
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
adminUrl?: string | null;
};
/**
* False when this session may not administer: the operator turned it off,
+2
View File
@@ -147,6 +147,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
"Nothing to show": "Nichts anzuzeigen",
"Could not be loaded": "Konnte nicht geladen werden",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in der Verwaltung von Stalwart selbst.",
"Open Stalwart admin": "Stalwart-Verwaltung öffnen",
"User": "Benutzer",
"Administrator": "Administrator",
"Custom role": "Eigene Rolle",
+2
View File
@@ -139,6 +139,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
"Nothing to show": "Nada que mostrar",
"Could not be loaded": "No se pudo cargar",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Las métricas detalladas, la cola de entrega, los registros y la configuración del servidor están en la administración de Stalwart.",
"Open Stalwart admin": "Abrir la administración de Stalwart",
"User": "Usuario",
"Administrator": "Administrador",
"Custom role": "Rol personalizado",
+2
View File
@@ -144,6 +144,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
"Nothing to show": "Rien à afficher",
"Could not be loaded": "Chargement impossible",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans ladministration de Stalwart.",
"Open Stalwart admin": "Ouvrir ladministration de Stalwart",
"User": "Utilisateur",
"Administrator": "Administrateur",
"Custom role": "Rôle personnalisé",
+2
View File
@@ -138,6 +138,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
"Nothing to show": "表示するものはありません",
"Could not be loaded": "読み込めませんでした",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "詳しいメトリクス、配送キュー、ログ、サーバー設定は Stalwart 自体の管理画面にあります。",
"Open Stalwart admin": "Stalwart の管理画面を開く",
"User": "ユーザー",
"Administrator": "管理者",
"Custom role": "カスタムロール",
+2
View File
@@ -135,6 +135,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
"Nothing to show": "Niets om te tonen",
"Could not be loaded": "Kon niet worden geladen",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in het eigen beheer van Stalwart.",
"Open Stalwart admin": "Stalwart-beheer openen",
"User": "Gebruiker",
"Administrator": "Beheerder",
"Custom role": "Aangepaste rol",
+2
View File
@@ -142,6 +142,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
"Nothing to show": "Nada para mostrar",
"Could not be loaded": "Não foi possível carregar",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Métricas detalhadas, a fila de entrega, os logs e as configurações do servidor ficam na administração do próprio Stalwart.",
"Open Stalwart admin": "Abrir a administração do Stalwart",
"User": "Usuário",
"Administrator": "Administrador",
"Custom role": "Função personalizada",
+2
View File
@@ -141,6 +141,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
"Nothing to show": "Нечего показать",
"Could not be loaded": "Не удалось загрузить",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в собственной панели администрирования Stalwart.",
"Open Stalwart admin": "Открыть администрирование Stalwart",
"User": "Пользователь",
"Administrator": "Администратор",
"Custom role": "Особая роль",
+2
View File
@@ -135,6 +135,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
"Nothing to show": "Нічого показати",
"Could not be loaded": "Не вдалося завантажити",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Докладні метрики, черга доставки, журнали та налаштування сервера є у власній панелі адміністрування Stalwart.",
"Open Stalwart admin": "Відкрити адміністрування Stalwart",
"User": "Користувач",
"Administrator": "Адміністратор",
"Custom role": "Власна роль",
+2
View File
@@ -137,6 +137,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
"Nothing to show": "没有可显示的内容",
"Could not be loaded": "无法加载",
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "详细指标、投递队列、日志和服务器设置位于 Stalwart 自身的管理界面中。",
"Open Stalwart admin": "打开 Stalwart 管理界面",
"User": "用户",
"Administrator": "管理员",
"Custom role": "自定义角色",
+3
View File
@@ -1750,6 +1750,9 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
pane beside it can be any width. */
.admin-dashboard { max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); }
.admin-cards-wrap { container: admin-cards / inline-size; margin-top: 8px; }
.admin-dashboard-note { margin-top: 16px; }
.admin-dashboard-note a { white-space: nowrap; }
.admin-dashboard-note svg { vertical-align: -1px; }
.admin-cards { display: grid; gap: 12px; grid-template-columns: repeat(var(--cols-wide, 3), minmax(0, 1fr)); max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); }
@container admin-cards (max-width: 760px) { .admin-cards { grid-template-columns: repeat(var(--cols-mid, 2), minmax(0, 1fr)); max-width: calc(var(--cols-mid, 2) * 300px + (var(--cols-mid, 2) - 1) * 12px); } }
@container admin-cards (max-width: 480px) { .admin-cards { grid-template-columns: minmax(0, 1fr); max-width: none; } }
+17 -1
View File
@@ -1,12 +1,13 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/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). */
@@ -33,6 +34,7 @@ async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
*/
export function AdminDashboard() {
const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const cards = dashboardCards(perms);
const sections = adminSections(perms);
const [reload, setReload] = useState(0);
@@ -109,6 +111,20 @@ export function AdminDashboard() {
) : (
<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 Stalwart's own administration.")}
{adminUrl && (
<>
{" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
{t("Open Stalwart admin")} <ExternalLink size={13} aria-hidden="true" />
</a>
</>
)}
</p>
</div>
);
}
@@ -111,3 +111,40 @@ describe("the Administration dashboard", () => {
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]);
});
});
describe("the pointer to Stalwart's own administration", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const renderWith = async (adminUrl: string | null) => {
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: HELPDESK, server: { edition: "enterprise", adminUrl } } } as unknown as JmapSession });
const { hook } = memoryLocation({ path: "/admin" });
await act(async () => {
root.render(<Router hook={hook}><AdminDashboard /></Router>);
});
await act(async () => {});
};
it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("Stalwart's own administration");
const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
it("names it without a link where nobody has", async () => {
await renderWith(null);
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("Stalwart's own administration");
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
});
});