From 4787e8bf12172f44a354bb627245c2b560560a40 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 15 Sep 2026 08:16:52 -0700 Subject: [PATCH] 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. --- FEATURES.md | 8 +++ README.md | 6 +- server/src/adminurl.test.ts | 52 ++++++++++++++++ server/src/app.ts | 9 ++- server/src/config.ts | 61 ++++++++++++++----- server/src/settingspolicy.test.ts | 12 +++- server/src/upstream.ts | 15 +++++ stalwart-servers.example.json | 8 ++- web/src/jmap/types.ts | 2 + web/src/locales/de.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/fr.ts | 2 + web/src/locales/ja.ts | 2 + web/src/locales/nl.ts | 2 + web/src/locales/pt-BR.ts | 2 + web/src/locales/ru.ts | 2 + web/src/locales/uk.ts | 2 + web/src/locales/zh-Hans.ts | 2 + web/src/styles/app.css | 3 + web/src/views/admin/AdminDashboard.tsx | 18 +++++- .../admin/__tests__/admin-dashboard.test.tsx | 37 +++++++++++ 21 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 server/src/adminurl.test.ts diff --git a/FEATURES.md b/FEATURES.md index 3abd1ae..53f5eaa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1152,6 +1152,13 @@ names Stalwart's own dashboard uses. The columns follow the number of cards, so rows come out even: six are three over three, and fall to two and then one as the space narrows. **Refresh** reads everything again; nothing is polled. +Below the cards, a line 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` — or, for a domain routed to +another server, that server's `adminUrl` in the servers file — and is plain text +otherwise, since the address ihasmail reaches Stalwart on is often not one a +browser can open. + ## Accounts - **List and search** by name or address, fifty to a page, newest first — the @@ -1594,6 +1601,7 @@ wizard, because either would be state. | Variable | Default | Does | | --- | --- | --- | | `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | +| `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link | | `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | | `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | | `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below | diff --git a/README.md b/README.md index 0830cfc..6000365 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,11 @@ installation that sets nothing else behaves exactly as it always has. ``` [`stalwart-servers.example.json`](stalwart-servers.example.json) is that file -with the rules written in it. +with the rules written in it. A domain's value may also be an object that names +where that server's own administration is, for the Administration dashboard's +link — `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`. +`STALWART_ADMIN_URL` is the same for the default server. A listed domain with no +`adminUrl` gets no link rather than the default server's. A domain nobody listed — and a bare username, which Stalwart accepts and which has no domain at all — goes to `STALWART_URL`. **A listed domain never falls diff --git a/server/src/adminurl.test.ts b/server/src/adminurl.test.ts new file mode 100644 index 0000000..44e2fc4 --- /dev/null +++ b/server/src/adminurl.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "ihasmail-servers-")); +const file = join(dir, "servers.json"); +writeFileSync( + file, + JSON.stringify({ + _comment: ["A note, as the example file has."], + "plain.test": "https://mail.plain.test/", + "Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" }, + }), +); +process.env.STALWART_URL = "https://default.example"; +process.env.STALWART_ADMIN_URL = "https://admin.default.example/"; +process.env.STALWART_SERVERS_FILE = file; + +const { adminUrlFor, upstreamFor } = await import("./upstream.js"); +const { config, parseStalwartServers } = await import("./config.js"); + +/** + * Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this + * server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its + * administration, and follows the same domain routing. + */ +test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => { + assert.deepEqual(config.stalwartServers, { "plain.test": "https://mail.plain.test", "linked.test": "https://mail.linked.test" }); + assert.deepEqual(config.stalwartAdminUrls, { "linked.test": "https://admin.linked.test" }); + assert.equal(upstreamFor("a@linked.test"), "https://mail.linked.test"); +}); + +test("an unmapped domain and a bare username open the default administration", () => { + assert.equal(adminUrlFor("a@anything.test"), "https://admin.default.example"); + assert.equal(adminUrlFor("demo"), "https://admin.default.example"); +}); + +test("a routed domain opens its own server's administration, and never the default's", () => { + assert.equal(adminUrlFor("a@linked.test"), "https://admin.linked.test"); + // Routed away, with no adminUrl of its own: no link rather than the wrong server. + assert.equal(adminUrlFor("a@plain.test"), null); +}); + +test("the shipped example loads through the parser that reads it", () => { + const example = new URL("../../stalwart-servers.example.json", import.meta.url); + const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example"); + assert.ok(Object.keys(parsed.urls).length > 0); + assert.ok(!("_comment" in parsed.urls)); + assert.equal(Object.keys(parsed.adminUrls).length, 1); +}); diff --git a/server/src/app.ts b/server/src/app.ts index f7245c1..44f5c8b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,6 +23,7 @@ import { getAccountInfo, getUpstreamSession, upstreamFor, + adminUrlFor, localizeSession, } from "./upstream.js"; import { @@ -865,8 +866,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, remember: session.remember, /** Locale configured for the account in Stalwart's directory, if readable. */ userLocale: info.locale, - /** What the upstream server would tell us about itself. */ - server: { edition: info.edition }, + /** + * What the upstream server would tell us about itself, and -- for a + * session that may administer -- where the operator says its own + * administration is. + */ + server: { edition: info.edition, adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null }, /** * Whether this session may administer: the installation offers it * (ADMINISTRATION) and the person signed in on a device marked as their own. diff --git a/server/src/config.ts b/server/src/config.ts index a3db72e..f9fc5d8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -202,9 +202,9 @@ function readSettingsPolicy(): { defaults: Record; enforced: Re * having an outage would take the other four down with it. What happens when * one is unreachable is a sign-in question, answered in #239. */ -function readStalwartServers(): Record { +function readStalwartServers(): { urls: Record; adminUrls: Record } { const file = process.env.STALWART_SERVERS_FILE; - if (!file) return {}; + if (!file) return { urls: {}, adminUrls: {} }; if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`); let raw: unknown; @@ -213,33 +213,55 @@ function readStalwartServers(): Record { } catch (err) { throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`); } + return parseStalwartServers(raw, file); +} + +/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */ +export function parseStalwartServers(raw: unknown, file: string): { urls: Record; adminUrls: Record } { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`); } const out: Record = {}; - for (const [rawDomain, rawUrl] of Object.entries(raw as Record)) { + const adminUrls: Record = {}; + for (const [rawDomain, rawValue] of Object.entries(raw as Record)) { + /* The example file explains itself in a `_comment` key, and a copy of it + used to stop the server as "not a URL". No mail domain starts with an + underscore, so a key that does is a note, not a mapping. */ + if (rawDomain.startsWith("_")) continue; /* Lower-cased and stripped of the root dot, because that is how a domain taken off a username will arrive and comparing them any other way means a mapping that silently never matches. */ const domain = rawDomain.trim().toLowerCase().replace(/\.$/, ""); if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`); if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalised`); - if (typeof rawUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`); - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`); + /* A domain's value is its server's URL, or an object that also names where + that server's own administration is: `{"url": …, "adminUrl": …}`. */ + const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record) : { url: rawValue }; + if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`); + out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`); + if (value.adminUrl !== undefined) { + if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`); + adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`); } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`); - } - out[domain] = rawUrl.replace(/\/+$/, ""); } - return out; + return { urls: out, adminUrls }; } +/** An absolute http(s) URL without its trailing slash, or a startup error naming where it came from. */ +function httpUrl(raw: string, where: string): string { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`Invalid ${where}: not an absolute URL`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`); + return raw.replace(/\/+$/, ""); +} + +const stalwartServers = readStalwartServers(); + export const config = { isProd, appName: env("APP_NAME", "ihasmail"), @@ -275,7 +297,16 @@ export const config = { */ basePath: normalizeBasePath(process.env.BASE_PATH), stalwartUrl, - stalwartServers: readStalwartServers(), + stalwartServers: stalwartServers.urls, + /** + * Where an administrator reaches Stalwart's own administration, for the + * pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL, + * which is how *this server* reaches Stalwart -- often an address no browser + * can open. Unset, the dashboard names Stalwart's administration without a + * link. A domain routed elsewhere takes its server's `adminUrl` instead. + */ + stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "", + stalwartAdminUrls: stalwartServers.adminUrls, appSecret, trustProxy: bool("TRUST_PROXY", true), /** diff --git a/server/src/settingspolicy.test.ts b/server/src/settingspolicy.test.ts index 6bc16f9..07d430e 100644 --- a/server/src/settingspolicy.test.ts +++ b/server/src/settingspolicy.test.ts @@ -74,9 +74,15 @@ test("every entry in the example mapping is a domain and an http(s) URL", () => assert.ok(domain, "a domain key is empty"); assert.ok(!seen.has(domain), `${domain} appears twice once normalised`); seen.add(domain); - assert.equal(typeof value, "string", `${domain} is not a string`); - const url = new URL(value as string); - assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`); + // A URL, or an object naming the server's URL and its administration's. + const entry = value && typeof value === "object" ? (value as Record) : { url: value }; + for (const [field, v] of Object.entries(entry)) { + assert.ok(field === "url" || field === "adminUrl", `${domain} has an unknown field ${field}`); + assert.equal(typeof v, "string", `${domain} ${field} is not a string`); + const url = new URL(v as string); + assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} ${field} must be http or https`); + } + assert.equal(typeof entry.url, "string", `${domain} has no url`); } assert.ok(seen.size > 0, "the example should show at least one mapping"); }); diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 13b9c64..aee042e 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -54,6 +54,21 @@ export function upstreamFor(username: string): string { return config.stalwartServers[domain] ?? config.stalwartUrl; } +/** + * Where the administrator signed in as `username` opens Stalwart's own + * administration, or null when the operator has not said. + * + * Follows the same routing as `upstreamFor`, and for the same reason never + * falls back: a domain routed to another server is not pointed at the default + * server's administration, where its accounts are not. + */ +export function adminUrlFor(username: string): string | null { + const at = username.lastIndexOf("@"); + const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, ""); + if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? null; + return config.stalwartAdminUrl || null; +} + export function wellKnownUrl(base: string = config.stalwartUrl): string { return `${base}/.well-known/jmap`; } diff --git a/stalwart-servers.example.json b/stalwart-servers.example.json index 8431cc4..ffcb9e6 100644 --- a/stalwart-servers.example.json +++ b/stalwart-servers.example.json @@ -17,9 +17,15 @@ "JSON, a duplicate domain, or a value that is not an http(s) URL stops the", "server at startup rather than failing quietly at somebody's sign-in.", "", + "A value may instead be an object that also says where that server's own", + "administration is, for the link on ihasmail's Administration dashboard:", + "{\"url\": ..., \"adminUrl\": ...}. STALWART_ADMIN_URL is the same for the", + "default server. A listed domain without adminUrl gets no link, never the", + "default server's.", + "", "Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers" ], "example.com": "https://mail.example.com", - "customer-b.test": "https://jmap.customer-b.test" + "customer-b.test": { "url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test" } } diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 5d9391f..a62d4f9 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -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, diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 3967642..214ea43 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index e049325..1e3aeb9 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 5192fb5..a590b65 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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 l’administration de Stalwart.", + "Open Stalwart admin": "Ouvrir l’administration de Stalwart", "User": "Utilisateur", "Administrator": "Administrateur", "Custom role": "Rôle personnalisé", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 91666f0..9e0363d 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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": "カスタムロール", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 3572498..9e3f177 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index aefafdf..d1b9dd2 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 9d534a0..44934cb 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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": "Особая роль", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index a15a58a..dca1481 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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": "Власна роль", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index cc70957..4f2220a 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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": "自定义角色", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 74d6df1..494725b 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } } diff --git a/web/src/views/admin/AdminDashboard.tsx b/web/src/views/admin/AdminDashboard.tsx index 580ac70..2a5784c 100644 --- a/web/src/views/admin/AdminDashboard.tsx +++ b/web/src/views/admin/AdminDashboard.tsx @@ -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(work: Promise): Promise> { */ 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() { ) : ( } 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. */} +

+ {t("Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.")} + {adminUrl && ( + <> + {" "} + + {t("Open Stalwart admin")} + + )} +

); } diff --git a/web/src/views/admin/__tests__/admin-dashboard.test.tsx b/web/src/views/admin/__tests__/admin-dashboard.test.tsx index ed52af4..bdb150c 100644 --- a/web/src/views/admin/__tests__/admin-dashboard.test.tsx +++ b/web/src/views/admin/__tests__/admin-dashboard.test.tsx @@ -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: "a@example.com", ihasmail: { permissions: HELPDESK, server: { edition: "enterprise", adminUrl } } } as unknown as JmapSession }); + const { hook } = memoryLocation({ path: "/admin" }); + await act(async () => { + root.render(); + }); + 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(); + }); +});