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
+8
View File
@@ -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 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. 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 ## Accounts
- **List and search** by name or address, fifty to a page, newest first — the - **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 | | Variable | Default | Does |
| --- | --- | --- | | --- | --- | --- |
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | | `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 | | `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 | | `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 | | `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 |
+5 -1
View File
@@ -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 [`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 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 has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
+52
View File
@@ -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("[email protected]"), "https://mail.linked.test");
});
test("an unmapped domain and a bare username open the default administration", () => {
assert.equal(adminUrlFor("[email protected]"), "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("[email protected]"), "https://admin.linked.test");
// Routed away, with no adminUrl of its own: no link rather than the wrong server.
assert.equal(adminUrlFor("[email protected]"), 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);
});
+7 -2
View File
@@ -23,6 +23,7 @@ import {
getAccountInfo, getAccountInfo,
getUpstreamSession, getUpstreamSession,
upstreamFor, upstreamFor,
adminUrlFor,
localizeSession, localizeSession,
} from "./upstream.js"; } from "./upstream.js";
import { import {
@@ -865,8 +866,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
remember: session.remember, remember: session.remember,
/** Locale configured for the account in Stalwart's directory, if readable. */ /** Locale configured for the account in Stalwart's directory, if readable. */
userLocale: info.locale, 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 * Whether this session may administer: the installation offers it
* (ADMINISTRATION) and the person signed in on a device marked as their own. * (ADMINISTRATION) and the person signed in on a device marked as their own.
+44 -13
View File
@@ -202,9 +202,9 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
* having an outage would take the other four down with it. What happens when * 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. * one is unreachable is a sign-in question, answered in #239.
*/ */
function readStalwartServers(): Record<string, string> { function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
const file = process.env.STALWART_SERVERS_FILE; 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}`); if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
let raw: unknown; let raw: unknown;
@@ -213,33 +213,55 @@ function readStalwartServers(): Record<string, string> {
} catch (err) { } catch (err) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`); 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<string, string>; adminUrls: Record<string, string> } {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`); throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
} }
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const [rawDomain, rawUrl] of Object.entries(raw as Record<string, unknown>)) { const adminUrls: Record<string, string> = {};
for (const [rawDomain, rawValue] of Object.entries(raw as Record<string, unknown>)) {
/* 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 /* 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 taken off a username will arrive and comparing them any other way means
a mapping that silently never matches. */ a mapping that silently never matches. */
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, ""); const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`); 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 (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`); /* 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<string, unknown>) : { 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`);
}
}
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; let parsed: URL;
try { try {
parsed = new URL(rawUrl); parsed = new URL(raw);
} catch { } catch {
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not an absolute URL`); throw new Error(`Invalid ${where}: not an absolute URL`);
} }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${where}: must be http or https`);
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" must be http or https`); return raw.replace(/\/+$/, "");
}
out[domain] = rawUrl.replace(/\/+$/, "");
}
return out;
} }
const stalwartServers = readStalwartServers();
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "ihasmail"), appName: env("APP_NAME", "ihasmail"),
@@ -275,7 +297,16 @@ export const config = {
*/ */
basePath: normalizeBasePath(process.env.BASE_PATH), basePath: normalizeBasePath(process.env.BASE_PATH),
stalwartUrl, 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, appSecret,
trustProxy: bool("TRUST_PROXY", true), trustProxy: bool("TRUST_PROXY", true),
/** /**
+9 -3
View File
@@ -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(domain, "a domain key is empty");
assert.ok(!seen.has(domain), `${domain} appears twice once normalised`); assert.ok(!seen.has(domain), `${domain} appears twice once normalised`);
seen.add(domain); seen.add(domain);
assert.equal(typeof value, "string", `${domain} is not a string`); // A URL, or an object naming the server's URL and its administration's.
const url = new URL(value as string); const entry = value && typeof value === "object" ? (value as Record<string, unknown>) : { url: value };
assert.ok(url.protocol === "http:" || url.protocol === "https:", `${domain} must be http or https`); 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"); assert.ok(seen.size > 0, "the example should show at least one mapping");
}); });
+15
View File
@@ -54,6 +54,21 @@ export function upstreamFor(username: string): string {
return config.stalwartServers[domain] ?? config.stalwartUrl; 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 { export function wellKnownUrl(base: string = config.stalwartUrl): string {
return `${base}/.well-known/jmap`; return `${base}/.well-known/jmap`;
} }
+7 -1
View File
@@ -17,9 +17,15 @@
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the", "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.", "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" "Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
], ],
"example.com": "https://mail.example.com", "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" }
} }
+2
View File
@@ -38,6 +38,8 @@ export interface JmapSession {
server?: { server?: {
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */ /** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null; 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, * 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.", "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", "Nothing to show": "Nichts anzuzeigen",
"Could not be loaded": "Konnte nicht geladen werden", "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", "User": "Benutzer",
"Administrator": "Administrator", "Administrator": "Administrator",
"Custom role": "Eigene Rolle", "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.", "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", "Nothing to show": "Nada que mostrar",
"Could not be loaded": "No se pudo cargar", "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", "User": "Usuario",
"Administrator": "Administrador", "Administrator": "Administrador",
"Custom role": "Rol personalizado", "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.", "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", "Nothing to show": "Rien à afficher",
"Could not be loaded": "Chargement impossible", "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", "User": "Utilisateur",
"Administrator": "Administrateur", "Administrator": "Administrateur",
"Custom role": "Rôle personnalisé", "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.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。", "The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
"Nothing to show": "表示するものはありません", "Nothing to show": "表示するものはありません",
"Could not be loaded": "読み込めませんでした", "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": "ユーザー", "User": "ユーザー",
"Administrator": "管理者", "Administrator": "管理者",
"Custom role": "カスタムロール", "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.", "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", "Nothing to show": "Niets om te tonen",
"Could not be loaded": "Kon niet worden geladen", "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", "User": "Gebruiker",
"Administrator": "Beheerder", "Administrator": "Beheerder",
"Custom role": "Aangepaste rol", "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.", "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", "Nothing to show": "Nada para mostrar",
"Could not be loaded": "Não foi possível carregar", "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", "User": "Usuário",
"Administrator": "Administrador", "Administrator": "Administrador",
"Custom role": "Função personalizada", "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.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.", "The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
"Nothing to show": "Нечего показать", "Nothing to show": "Нечего показать",
"Could not be loaded": "Не удалось загрузить", "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": "Пользователь", "User": "Пользователь",
"Administrator": "Администратор", "Administrator": "Администратор",
"Custom role": "Особая роль", "Custom role": "Особая роль",
+2
View File
@@ -135,6 +135,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.", "The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
"Nothing to show": "Нічого показати", "Nothing to show": "Нічого показати",
"Could not be loaded": "Не вдалося завантажити", "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": "Користувач", "User": "Користувач",
"Administrator": "Адміністратор", "Administrator": "Адміністратор",
"Custom role": "Власна роль", "Custom role": "Власна роль",
+2
View File
@@ -137,6 +137,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。", "The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
"Nothing to show": "没有可显示的内容", "Nothing to show": "没有可显示的内容",
"Could not be loaded": "无法加载", "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": "用户", "User": "用户",
"Administrator": "管理员", "Administrator": "管理员",
"Custom role": "自定义角色", "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. */ pane beside it can be any width. */
.admin-dashboard { max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); } .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-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); } .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: 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; } } @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 { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter"; 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 { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard"; import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard";
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime"; import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { Empty } from "@/ui/misc"; import { Empty } from "@/ui/misc";
import { useSession } from "@/store/session";
import { usePermissions } from "./usePermissions"; import { usePermissions } from "./usePermissions";
/** Loading, a number, refused by the server (the card goes), or failed (the card says so). */ /** 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() { export function AdminDashboard() {
const perms = usePermissions(); const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const cards = dashboardCards(perms); const cards = dashboardCards(perms);
const sections = adminSections(perms); const sections = adminSections(perms);
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
@@ -109,6 +111,20 @@ export function AdminDashboard() {
) : ( ) : (
<Empty icon={<LayoutDashboard size={32} />} title={t("Nothing to show")} /> <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> </div>
); );
} }
@@ -111,3 +111,40 @@ describe("the Administration dashboard", () => {
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]); 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();
});
});