Merge pull request #360 from Coffey-Labs/feat/admin-dashboard
Open Administration on a dashboard of what the role can read
This commit is contained in:
+53
-10
@@ -1108,8 +1108,10 @@ redirects them to their mail if they type its address in.
|
||||
At sign-in the server already asks Stalwart's `GET /api/account` for the
|
||||
edition; it now keeps the account's **permissions** from the same answer and
|
||||
hands them to the browser with the session. The menu appears for an account
|
||||
that can query and read accounts (`sysAccountQuery`, `sysAccountGet`) or
|
||||
domains (`sysDomainQuery`, `sysDomainGet`), and each control inside is there only when the matching permission is:
|
||||
that can count something the dashboard shows — accounts (`sysAccountQuery`),
|
||||
domains (`sysDomainQuery`), the delivery queue (`sysQueuedMessageQuery`) or the
|
||||
metric history (`sysMetricQuery` with `sysMetricGet`) — and each section and
|
||||
control inside is there only when the matching permission is:
|
||||
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
||||
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
||||
administrator and a custom helpdesk role each see the same screen shaped to
|
||||
@@ -1121,6 +1123,42 @@ account, and Stalwart decides each one — scoping a tenant administrator's
|
||||
queries to their own tenant and refusing anything the role does not allow.
|
||||
The client's gating only avoids offering what would fail.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Administration opens on a grid of cards, one for each number the role can read:
|
||||
|
||||
| Card | What it counts | Needs |
|
||||
|---|---|---|
|
||||
| **Users** | user accounts, not groups | `sysAccountQuery` |
|
||||
| **Domains** | mail domains | `sysDomainQuery` |
|
||||
| **Pending** | messages waiting in the delivery queue | `sysQueuedMessageQuery` |
|
||||
| **Server memory** | the latest reading, and when it was taken | `sysMetricQuery`, `sysMetricGet` |
|
||||
| **Received** | messages queued for delivery in the last 24 hours | the same |
|
||||
| **Sent** | authenticated submissions, bounces and reports queued in the last 24 hours | the same |
|
||||
|
||||
Users and Domains open their sections when the role can. The counts are what
|
||||
Stalwart answers for the signed-in account, so a **tenant administrator sees
|
||||
their tenancy**: its accounts, its domains, and the queued messages that touch
|
||||
them. The last three come from Stalwart's metric history, which has no tenant
|
||||
in it and which the Tenant Administrator role Stalwart creates does not hold,
|
||||
so a tenant's dashboard is Users, Domains and Pending. A helpdesk role that can
|
||||
read accounts and domains sees those two cards.
|
||||
|
||||
The history is an Enterprise feature that has to be switched on. A server that
|
||||
refuses it — Community does — leaves those three cards off rather than showing
|
||||
them broken, and one that records nothing says *Not recorded on this server*
|
||||
rather than showing a day of zeroes. Received and sent add up the same metric
|
||||
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
|
||||
@@ -1202,9 +1240,10 @@ session information already kept for thirty minutes — so a role granted or
|
||||
taken away shows in the menu at the next sign-in or within half an hour, and in
|
||||
the meantime Stalwart refuses what is no longer allowed.
|
||||
|
||||
Accounts and domains are the first two sections. Groups, mailing lists, roles
|
||||
and tenants are Stalwart capabilities the same screen is laid out to take;
|
||||
reporting, queues, logs and server settings are deliberately out of scope.
|
||||
The dashboard, accounts and domains are the first three sections. Groups,
|
||||
mailing lists, roles and tenants are Stalwart capabilities the same screen is
|
||||
laid out to take. Beyond the dashboard's counts, managing queues, logs and
|
||||
server settings is deliberately out of scope.
|
||||
|
||||
---
|
||||
|
||||
@@ -1562,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 |
|
||||
@@ -1662,11 +1702,14 @@ moves an occurrence renumbering the ids around it. Two switches:
|
||||
tested.
|
||||
|
||||
Administration works against it too, with a directory of about thirty accounts,
|
||||
three domains with their DKIM keys and zone files, behind the same permission
|
||||
names Stalwart uses. `MOCK_ROLE` decides who the
|
||||
demo user is: `admin` (the default), `tenant-admin`, `helpdesk` — a custom role
|
||||
that may view and edit accounts but not create or delete them — or `user`, who
|
||||
is not offered the menu at all.
|
||||
three domains with their DKIM keys and zone files, nine queued messages and
|
||||
thirty hours of metric history ending in the current hour, behind the same
|
||||
permission names Stalwart uses. `MOCK_ROLE` decides who the
|
||||
demo user is: `admin` (the default), `tenant-admin` (the queue but not the
|
||||
history), `helpdesk` — a custom role that may view and edit accounts but not
|
||||
create or delete them, and read domains — or `user`, who is not offered the
|
||||
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
|
||||
does.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
|
||||
The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials/<index>/secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason.
|
||||
|
||||
- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below:
|
||||
|
||||
- **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500.
|
||||
- **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them.
|
||||
- **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`.
|
||||
- **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`.
|
||||
|
||||
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
|
||||
|
||||
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
||||
|
||||
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
|
||||
|
||||
@@ -83,7 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
@@ -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
|
||||
@@ -399,12 +403,13 @@ without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's. Four switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
RFC 8984's. Five switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
||||
calendar write that asks for scheduling messages, the way an account without
|
||||
that permission is refused; and `MOCK_ROLE` decides who the demo user is for
|
||||
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`.
|
||||
that permission is refused; `MOCK_ROLE` decides who the demo user is for
|
||||
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`;
|
||||
and `MOCK_METRICS=off` refuses the dashboard's metric history, as Community does.
|
||||
|
||||
It tracks the current release rather than 0.16 in general, and each behaviour
|
||||
is confirmed against a real server before it is copied here — the comments say
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **Administration beyond accounts and domains.** The Administration menu manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. Reporting, queues, logs and server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Administration beyond accounts and domains.** The Administration menu opens on a dashboard and manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
||||
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
|
||||
|
||||
@@ -57,7 +57,12 @@ test("administration needs both the installation and a device marked as the pers
|
||||
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false);
|
||||
// The dashboard opens on less than a list: a count is only a query.
|
||||
assert.equal(grantsAdministration(["sysAccountQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysMetricQuery"]), false);
|
||||
assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false);
|
||||
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,10 +41,15 @@ export function administrationAllowed(enabled: boolean, remember: boolean): bool
|
||||
* Whether an account's permissions would put Administration in its menu --
|
||||
* the same test the client makes, so the server can say why it is missing
|
||||
* without handing over the permissions themselves.
|
||||
*
|
||||
* The client's test is whether any section opens, and the dashboard opens on
|
||||
* less than a list does: a count needs only the query, the metric history its
|
||||
* query and get. The account and domain lists need more than their counts, so
|
||||
* they add nothing here.
|
||||
*/
|
||||
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||
const has = new Set(permissions);
|
||||
return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet"));
|
||||
return has.has("sysAccountQuery") || has.has("sysDomainQuery") || has.has("sysQueuedMessageQuery") || (has.has("sysMetricQuery") && has.has("sysMetricGet"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+46
-15
@@ -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
|
||||
* 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;
|
||||
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<string, string> {
|
||||
} 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<string, string>; adminUrls: Record<string, string> } {
|
||||
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<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
|
||||
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<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`);
|
||||
}
|
||||
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),
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ class Refused extends Error {
|
||||
constructor(readonly type: string, description?: string) { super(description ?? type); }
|
||||
}
|
||||
|
||||
const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d) });
|
||||
const make = (role: MockRole, extra: { metricsOff?: boolean; now?: Date } = {}) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d), ...extra });
|
||||
|
||||
/**
|
||||
* The mock stands in for a server that decides what each account may do, so
|
||||
@@ -109,3 +109,41 @@ test("the domain validators refuse what the live server refused, in its words",
|
||||
const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||
assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]);
|
||||
});
|
||||
|
||||
/** The dashboard's feeds: counts, the queue, and the metric history. */
|
||||
test("counts come back with no ids when the client asks for a total and no page", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:QueuedMessage/query"]!({ limit: 0, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.deepEqual(r.ids, []);
|
||||
assert.equal(r.total, 9);
|
||||
});
|
||||
|
||||
test("the metric history answers the filter the dashboard sends, newest first", () => {
|
||||
const dir = make("admin", { now: new Date("2026-09-15T14:25:00Z") });
|
||||
const q = dir.handlers["x:Metric/query"]!({
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T14:25:00Z", metric: ["server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
}) as { ids: string[] };
|
||||
const { list } = dir.handlers["x:Metric/get"]!({ ids: q.ids }) as { list: Array<{ metric: string; timestamp: string }> };
|
||||
assert.equal(list.length, 24);
|
||||
assert.ok(list.every((m) => m.metric === "server.memory"));
|
||||
const newest = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[0]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
const next = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[1]] }) as { list: Array<{ timestamp: string }> }).list[0]!;
|
||||
assert.equal(newest.timestamp, "2026-09-15T14:00:00Z");
|
||||
assert.ok(newest.timestamp > next.timestamp);
|
||||
// A bare timestamp is what a live server refuses.
|
||||
assert.throws(() => dir.handlers["x:Metric/query"]!({ filter: { timestamp: "2026-09-15T00:00:00Z" } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||
});
|
||||
|
||||
test("a tenant administrator gets the queue but not the history, and Community refuses the history", () => {
|
||||
const tenant = make("tenant-admin");
|
||||
assert.equal((tenant.handlers["x:QueuedMessage/query"]!({ calculateTotal: true }) as { total: number }).total, 9);
|
||||
assert.throws(() => tenant.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||
const community = make("admin", { metricsOff: true });
|
||||
assert.throws(() => community.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden" && /Enterprise/.test(e.message));
|
||||
});
|
||||
|
||||
test("helpdesk may count domains, which is what the demo's helpdesk may do", () => {
|
||||
assert.ok(permissionsFor("helpdesk").includes("sysDomainQuery"));
|
||||
assert.ok(!permissionsFor("helpdesk").includes("sysMetricQuery"));
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* credential's own pointer, `credentials/<index>/secret`;
|
||||
* - `x:Account/query` understands AND and nothing else.
|
||||
*
|
||||
* It also answers the two feeds Administration's dashboard reads: a short
|
||||
* outbound queue (`x:QueuedMessage`) and a day and a bit of hourly metric
|
||||
* history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off
|
||||
* refuses the history the way a Community server does.
|
||||
*
|
||||
* What it does not reproduce is tenancy: every caller sees every record. The
|
||||
* real server scopes a tenant administrator's queries, and nothing in the client
|
||||
* relies on seeing more or less than it is given.
|
||||
@@ -29,17 +34,22 @@ export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
|
||||
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||
|
||||
/** What the dashboard reads beyond the directory. */
|
||||
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"];
|
||||
|
||||
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||
|
||||
export function permissionsFor(role: MockRole): string[] {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"];
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "impersonate"];
|
||||
case "tenant-admin":
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
|
||||
// The queue but not the metric history: Stalwart scopes the one to a
|
||||
// tenant's domains, and the other has no tenant to scope it by.
|
||||
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer"), "sysQueuedMessageGet", "sysQueuedMessageQuery"];
|
||||
case "helpdesk":
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
|
||||
default:
|
||||
return USER_PERMISSIONS;
|
||||
}
|
||||
@@ -61,6 +71,10 @@ interface Options {
|
||||
role: MockRole;
|
||||
/** Build the error a method fails with; the mock server owns the type. */
|
||||
fail: (type: string, description?: string) => Error;
|
||||
/** Refuse the metric history, as a Community server does. */
|
||||
metricsOff?: boolean;
|
||||
/** When the history ends; the newest hour is the one this falls in. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export function createDirectory(opts: Options) {
|
||||
@@ -168,6 +182,38 @@ export function createDirectory(opts: Options) {
|
||||
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
|
||||
});
|
||||
|
||||
// Nine messages waiting, which is what a small live server had queued on the
|
||||
// day this was written: a few retries and the odd report.
|
||||
const queue: Obj[] = Array.from({ length: 9 }, (_, i) => ({ id: `q${i + 1}`, createdAt: new Date(Date.UTC(2026, 8, 15, 6 + i)).toISOString(), size: 2400 + i * 310, priority: 0, flags: {} }));
|
||||
|
||||
/**
|
||||
* Thirty hours of history ending in the current hour: a Counter per hour for
|
||||
* what was queued, and a memory Gauge. Counters that would be zero are left
|
||||
* out, as Stalwart leaves them out.
|
||||
*/
|
||||
const metrics: Obj[] = [];
|
||||
{
|
||||
const hour = 3600_000;
|
||||
const end = Math.floor((opts.now ?? new Date()).getTime() / hour) * hour;
|
||||
for (let h = 29; h >= 0; h--) {
|
||||
const at = end - h * hour;
|
||||
const timestamp = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const seq = (29 - h) * 10;
|
||||
const push = (n: number, type: string, metric: string, count: number) => {
|
||||
if (type === "Counter" && !count) return;
|
||||
metrics.push({ id: `m${String(seq + n).padStart(4, "0")}`, "@type": type, metric, count, timestamp });
|
||||
};
|
||||
push(0, "Gauge", "server.memory", 360_000_000 + ((h * 7_919_000) % 40_000_000));
|
||||
push(1, "Counter", "queue.message-queued", (h * 5 + 3) % 9);
|
||||
push(2, "Counter", "queue.authenticated-message-queued", h % 3);
|
||||
push(3, "Counter", "queue.dsn-queued", h % 11 === 0 ? 1 : 0);
|
||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||
}
|
||||
}
|
||||
const refuseMetrics = () => {
|
||||
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
|
||||
};
|
||||
|
||||
const demand = (perm: string) => {
|
||||
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
|
||||
};
|
||||
@@ -394,6 +440,21 @@ export function createDirectory(opts: Options) {
|
||||
demand("sysDnsServerGet");
|
||||
return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] };
|
||||
},
|
||||
"x:QueuedMessage/get": get(queue, "sysQueuedMessageGet"),
|
||||
"x:QueuedMessage/query": query(() => queue, "sysQueuedMessageQuery", [], () => true),
|
||||
"x:Metric/get": (a) => {
|
||||
refuseMetrics();
|
||||
return get(metrics, "sysMetricGet")(a);
|
||||
},
|
||||
// Ids sort the way timestamps do, so the helper's newest-first order is the
|
||||
// `timestamp` descending the dashboard asks for.
|
||||
"x:Metric/query": (a) => {
|
||||
refuseMetrics();
|
||||
return query(() => metrics, "sysMetricQuery", ["timestampIsGreaterThanOrEqual", "timestampIsLessThanOrEqual", "metric"], (o, f) =>
|
||||
(f.timestampIsGreaterThanOrEqual === undefined || String(o.timestamp) >= String(f.timestampIsGreaterThanOrEqual)) &&
|
||||
(f.timestampIsLessThanOrEqual === undefined || String(o.timestamp) <= String(f.timestampIsLessThanOrEqual)) &&
|
||||
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
|
||||
},
|
||||
"x:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
||||
};
|
||||
|
||||
@@ -892,6 +892,7 @@ const directory = createDirectory({
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
metricsOff: process.env.MOCK_METRICS === "off",
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>) : { 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");
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
@@ -12,17 +12,27 @@ const roles = new Map<string, RoleDef>([
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list", () => {
|
||||
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||
it("needs both halves of reading the account list to list accounts", () => {
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts"]);
|
||||
// A query alone is a count on the dashboard, not a list.
|
||||
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers each section only with both halves of reading it", () => {
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["domains"]);
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
|
||||
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["accounts"]);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts"]);
|
||||
});
|
||||
|
||||
it("gives the dashboard a card for each number the role can read", () => {
|
||||
expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]);
|
||||
expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]);
|
||||
// The history takes its get as well: the query only finds the records.
|
||||
expect(dashboardCards(set("sysMetricQuery"))).toEqual([]);
|
||||
expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]);
|
||||
expect(adminSections(set("jmapEmailGet"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
|
||||
|
||||
describe("the dashboard's message numbers", () => {
|
||||
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
|
||||
const stats = summariseMetrics([
|
||||
counter("queue.message-queued", 6),
|
||||
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
|
||||
counter("queue.authenticated-message-queued", 2),
|
||||
counter("queue.dsn-queued", 1),
|
||||
counter("queue.report-queued", 3),
|
||||
// Recorded, but not either number.
|
||||
counter("message-ingest.ham", 50),
|
||||
]);
|
||||
expect(stats.received).toBe(10);
|
||||
expect(stats.sent).toBe(6);
|
||||
});
|
||||
|
||||
it("reads memory from the newest gauge, not the first one listed", () => {
|
||||
const stats = summariseMetrics([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
|
||||
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
|
||||
]);
|
||||
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
|
||||
});
|
||||
|
||||
it("tells a history that records nothing from a quiet day", () => {
|
||||
expect(summariseMetrics([]).recorded).toBe(false);
|
||||
const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
|
||||
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dashboard's queries", () => {
|
||||
it("counts users rather than accounts, and asks for no ids", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
|
||||
expect(await countObjects("Account")).toBe(5);
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
|
||||
await countObjects("QueuedMessage");
|
||||
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
|
||||
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
|
||||
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
|
||||
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
|
||||
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
|
||||
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
|
||||
});
|
||||
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
|
||||
expect(records).toHaveLength(3);
|
||||
expect(call.mock.calls[0]).toEqual([
|
||||
"x:Metric/query",
|
||||
{
|
||||
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
|
||||
sort: [{ property: "timestamp", isAscending: false }],
|
||||
position: 0,
|
||||
limit: 2,
|
||||
},
|
||||
]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats only a forbidden answer as the server refusing", () => {
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
|
||||
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
|
||||
expect(isRefused(new Error("offline"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card grid", () => {
|
||||
it("never leaves a row short when the cards can be divided evenly", () => {
|
||||
for (const n of [1, 2, 3, 4, 6]) {
|
||||
const { wide, mid } = balancedColumns(n);
|
||||
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
|
||||
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
|
||||
expect(wide).toBeLessThanOrEqual(4);
|
||||
}
|
||||
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
|
||||
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* for a check Stalwart does not make. See there.
|
||||
*/
|
||||
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric";
|
||||
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||
|
||||
export type Permissions = ReadonlySet<string>;
|
||||
@@ -27,16 +27,39 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
export type AdminSection = "accounts" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
/**
|
||||
* The dashboard's cards an account may see.
|
||||
*
|
||||
* A count is a query with `calculateTotal`, so a query alone earns one. The
|
||||
* three read from the metric history need the get as well, since the query
|
||||
* only finds the records. Stalwart scopes the first three to a tenant
|
||||
* administrator's own tenancy; the metric history has no tenant in it at all,
|
||||
* and the Tenant Administrator role Stalwart creates does not hold it -- which
|
||||
* is how a tenant's dashboard comes to show only what is theirs.
|
||||
*/
|
||||
export function dashboardCards(perms: Permissions): DashboardCard[] {
|
||||
const out: DashboardCard[] = [];
|
||||
if (can(perms, "Account", "Query")) out.push("users");
|
||||
if (can(perms, "Domain", "Query")) out.push("domains");
|
||||
if (can(perms, "QueuedMessage", "Query")) out.push("pending");
|
||||
if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections an account may open, in the order they are listed.
|
||||
*
|
||||
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||
* of reading one: the query that finds the objects and the get that shows them.
|
||||
* The dashboard comes first, and is there whenever it has a card to show.
|
||||
*/
|
||||
export function adminSections(perms: Permissions): AdminSection[] {
|
||||
const out: AdminSection[] = [];
|
||||
if (dashboardCards(perms).length) out.push("dashboard");
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts");
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { client, JmapMethodError } from "@/jmap/client";
|
||||
|
||||
/**
|
||||
* The numbers on Administration's dashboard, over the ordinary JMAP proxy.
|
||||
*
|
||||
* Counts are queries with `calculateTotal` and `limit: 0`: Stalwart lifts its
|
||||
* own limit when asked for a total, so the number is the whole count, and no
|
||||
* ids come back to be thrown away. For a tenant administrator the server scopes
|
||||
* all three to the tenancy -- accounts and domains to its members, the queue to
|
||||
* messages touching its domains.
|
||||
*
|
||||
* The rest is read from `x:Metric`, the history Stalwart records once per
|
||||
* collection interval (hourly by default): a Counter holds what happened in
|
||||
* that interval, a Gauge the reading at its end. Received and sent are the sums
|
||||
* Stalwart's own dashboard shows, over the same metric names. The history is
|
||||
* Enterprise-only and has to be switched on (`x:MetricsStore`); a Community
|
||||
* server refuses the query as `forbidden`, and one that records nothing
|
||||
* answers with nothing -- the two cases the dashboard tells apart.
|
||||
*/
|
||||
|
||||
export const RECEIVED_METRICS = ["queue.message-queued"] as const;
|
||||
export const SENT_METRICS = ["queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued"] as const;
|
||||
export const MEMORY_METRIC = "server.memory";
|
||||
|
||||
/** The window received and sent cover. */
|
||||
export const DASHBOARD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface MetricRecord {
|
||||
"@type": "Counter" | "Gauge" | "Histogram";
|
||||
metric: string;
|
||||
count: number;
|
||||
timestamp: string;
|
||||
sum?: number;
|
||||
}
|
||||
|
||||
export interface MessageStats {
|
||||
received: number;
|
||||
sent: number;
|
||||
/** The latest memory reading, or null when none was recorded in the window. */
|
||||
memory: { bytes: number; at: string } | null;
|
||||
/**
|
||||
* Whether the server recorded anything in the window. Memory is written every
|
||||
* interval, so a window with no records at all is a history that is switched
|
||||
* off -- not a quiet day, which would still say zero.
|
||||
*/
|
||||
recorded: boolean;
|
||||
}
|
||||
|
||||
export function summariseMetrics(records: readonly MetricRecord[]): MessageStats {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let memory: MessageStats["memory"] = null;
|
||||
const receivedNames = new Set<string>(RECEIVED_METRICS);
|
||||
const sentNames = new Set<string>(SENT_METRICS);
|
||||
for (const r of records) {
|
||||
if (r["@type"] === "Counter") {
|
||||
if (receivedNames.has(r.metric)) received += r.count;
|
||||
else if (sentNames.has(r.metric)) sent += r.count;
|
||||
} else if (r["@type"] === "Gauge" && r.metric === MEMORY_METRIC) {
|
||||
if (!memory || r.timestamp > memory.at) memory = { bytes: r.count, at: r.timestamp };
|
||||
}
|
||||
}
|
||||
return { received, sent, memory, recorded: records.length > 0 };
|
||||
}
|
||||
|
||||
type CountedObject = "Account" | "Domain" | "QueuedMessage";
|
||||
|
||||
/** How many there are. Accounts are counted as users: groups are accounts too. */
|
||||
export async function countObjects(object: CountedObject): Promise<number> {
|
||||
const res = await client.call<{ total?: number; ids?: string[] }>(`x:${object}/query`, {
|
||||
...(object === "Account" ? { filter: { "@type": "User" } } : {}),
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
});
|
||||
return res.total ?? res.ids?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every record of the dashboard's metrics since `since`, newest first.
|
||||
*
|
||||
* The filter keys are Stalwart's comparison names for the property -- a bare
|
||||
* `timestamp` is `unsupportedFilter`. A day at the default hourly interval is
|
||||
* well under one page; the paging is for a server that collects far more often.
|
||||
*/
|
||||
export async function loadMetrics(since: Date): Promise<MetricRecord[]> {
|
||||
const filter = {
|
||||
timestampIsGreaterThanOrEqual: since.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
metric: [...RECEIVED_METRICS, ...SENT_METRICS, MEMORY_METRIC],
|
||||
};
|
||||
const step = client.maxObjectsInGet;
|
||||
const out: MetricRecord[] = [];
|
||||
for (let position = 0; ; position += step) {
|
||||
const q = await client.call<{ ids?: string[] }>("x:Metric/query", { filter, sort: [{ property: "timestamp", isAscending: false }], position, limit: step });
|
||||
const ids = q.ids ?? [];
|
||||
if (ids.length) out.push(...(await client.call<{ list: MetricRecord[] }>("x:Metric/get", { ids })).list);
|
||||
if (ids.length < step) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns the cards take, so no row is left short.
|
||||
*
|
||||
* `wide` is the most that divides the cards evenly without going past four;
|
||||
* `mid` is what they drop to when that no longer fits, again only a count the
|
||||
* cards divide into -- three cards go to one column rather than two and one.
|
||||
* Five is the one count nothing divides, and takes three over two. A phone
|
||||
* always gets one column, which the stylesheet decides.
|
||||
*/
|
||||
export function balancedColumns(cards: number): { wide: number; mid: number } {
|
||||
switch (cards) {
|
||||
case 0:
|
||||
case 1:
|
||||
return { wide: 1, mid: 1 };
|
||||
case 2:
|
||||
return { wide: 2, mid: 2 };
|
||||
case 3:
|
||||
return { wide: 3, mid: 1 };
|
||||
case 4:
|
||||
return { wide: 4, mid: 2 };
|
||||
default:
|
||||
return { wide: 3, mid: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A refusal from the server itself, as opposed to a failure to reach it. */
|
||||
export function isRefused(err: unknown): boolean {
|
||||
return err instanceof JmapMethodError && err.error.type === "forbidden";
|
||||
}
|
||||
@@ -133,6 +133,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||
"Administration": "Verwaltung",
|
||||
"Directory": "Verzeichnis",
|
||||
"Overview": "Übersicht",
|
||||
"Dashboard": "Dashboard",
|
||||
"Users": "Benutzer",
|
||||
"User accounts": "Benutzerkonten",
|
||||
"Mail domains": "E-Mail-Domains",
|
||||
"Pending": "Ausstehend",
|
||||
"Waiting in the delivery queue": "In der Zustellwarteschlange",
|
||||
"Server memory": "Arbeitsspeicher des Servers",
|
||||
"As of {time}": "Stand: {time}",
|
||||
"Not recorded on this server": "Auf diesem Server nicht erfasst",
|
||||
"Last 24 hours": "Letzte 24 Stunden",
|
||||
"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",
|
||||
|
||||
@@ -125,6 +125,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||
"Administration": "Administración",
|
||||
"Directory": "Directorio",
|
||||
"Overview": "Resumen",
|
||||
"Dashboard": "Panel",
|
||||
"Users": "Usuarios",
|
||||
"User accounts": "Cuentas de usuario",
|
||||
"Mail domains": "Dominios de correo",
|
||||
"Pending": "Pendientes",
|
||||
"Waiting in the delivery queue": "En la cola de entrega",
|
||||
"Server memory": "Memoria del servidor",
|
||||
"As of {time}": "A las {time}",
|
||||
"Not recorded on this server": "No se registra en este servidor",
|
||||
"Last 24 hours": "Últimas 24 horas",
|
||||
"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",
|
||||
|
||||
@@ -130,6 +130,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||
"Administration": "Administration",
|
||||
"Directory": "Annuaire",
|
||||
"Overview": "Vue d’ensemble",
|
||||
"Dashboard": "Tableau de bord",
|
||||
"Users": "Utilisateurs",
|
||||
"User accounts": "Comptes utilisateurs",
|
||||
"Mail domains": "Domaines de messagerie",
|
||||
"Pending": "En attente",
|
||||
"Waiting in the delivery queue": "Dans la file de distribution",
|
||||
"Server memory": "Mémoire du serveur",
|
||||
"As of {time}": "Au {time}",
|
||||
"Not recorded on this server": "Non enregistré sur ce serveur",
|
||||
"Last 24 hours": "Dernières 24 heures",
|
||||
"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é",
|
||||
|
||||
@@ -124,6 +124,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||
"Administration": "管理",
|
||||
"Directory": "ディレクトリ",
|
||||
"Overview": "概要",
|
||||
"Dashboard": "ダッシュボード",
|
||||
"Users": "ユーザー",
|
||||
"User accounts": "ユーザーアカウント",
|
||||
"Mail domains": "メールドメイン",
|
||||
"Pending": "保留中",
|
||||
"Waiting in the delivery queue": "配送キューで待機中",
|
||||
"Server memory": "サーバーのメモリ",
|
||||
"As of {time}": "{time} 時点",
|
||||
"Not recorded on this server": "このサーバーでは記録されていません",
|
||||
"Last 24 hours": "過去 24 時間",
|
||||
"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": "カスタムロール",
|
||||
|
||||
@@ -121,6 +121,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||
"Administration": "Beheer",
|
||||
"Directory": "Adreslijst",
|
||||
"Overview": "Overzicht",
|
||||
"Dashboard": "Dashboard",
|
||||
"Users": "Gebruikers",
|
||||
"User accounts": "Gebruikersaccounts",
|
||||
"Mail domains": "E-maildomeinen",
|
||||
"Pending": "In behandeling",
|
||||
"Waiting in the delivery queue": "In de bezorgwachtrij",
|
||||
"Server memory": "Servergeheugen",
|
||||
"As of {time}": "Stand van {time}",
|
||||
"Not recorded on this server": "Niet vastgelegd op deze server",
|
||||
"Last 24 hours": "Afgelopen 24 uur",
|
||||
"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",
|
||||
|
||||
@@ -128,6 +128,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||
"Administration": "Administração",
|
||||
"Directory": "Diretório",
|
||||
"Overview": "Visão geral",
|
||||
"Dashboard": "Painel",
|
||||
"Users": "Usuários",
|
||||
"User accounts": "Contas de usuário",
|
||||
"Mail domains": "Domínios de e-mail",
|
||||
"Pending": "Pendentes",
|
||||
"Waiting in the delivery queue": "Na fila de entrega",
|
||||
"Server memory": "Memória do servidor",
|
||||
"As of {time}": "Em {time}",
|
||||
"Not recorded on this server": "Não registrado neste servidor",
|
||||
"Last 24 hours": "Últimas 24 horas",
|
||||
"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",
|
||||
|
||||
@@ -127,6 +127,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||
"Administration": "Администрирование",
|
||||
"Directory": "Каталог",
|
||||
"Overview": "Обзор",
|
||||
"Dashboard": "Панель",
|
||||
"Users": "Пользователи",
|
||||
"User accounts": "Учётные записи пользователей",
|
||||
"Mail domains": "Почтовые домены",
|
||||
"Pending": "В очереди",
|
||||
"Waiting in the delivery queue": "Ожидают в очереди доставки",
|
||||
"Server memory": "Память сервера",
|
||||
"As of {time}": "На {time}",
|
||||
"Not recorded on this server": "На этом сервере не записывается",
|
||||
"Last 24 hours": "За последние 24 часа",
|
||||
"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": "Особая роль",
|
||||
|
||||
@@ -121,6 +121,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||
"Administration": "Адміністрування",
|
||||
"Directory": "Каталог",
|
||||
"Overview": "Огляд",
|
||||
"Dashboard": "Панель",
|
||||
"Users": "Користувачі",
|
||||
"User accounts": "Облікові записи користувачів",
|
||||
"Mail domains": "Поштові домени",
|
||||
"Pending": "У черзі",
|
||||
"Waiting in the delivery queue": "Очікують у черзі доставки",
|
||||
"Server memory": "Пам’ять сервера",
|
||||
"As of {time}": "Станом на {time}",
|
||||
"Not recorded on this server": "На цьому сервері не записується",
|
||||
"Last 24 hours": "За останні 24 години",
|
||||
"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": "Власна роль",
|
||||
|
||||
@@ -123,6 +123,22 @@ export const catalog: Catalog = {
|
||||
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
||||
"Administration": "管理",
|
||||
"Directory": "目录",
|
||||
"Overview": "概览",
|
||||
"Dashboard": "仪表板",
|
||||
"Users": "用户",
|
||||
"User accounts": "用户账户",
|
||||
"Mail domains": "邮件域名",
|
||||
"Pending": "待处理",
|
||||
"Waiting in the delivery queue": "在投递队列中等待",
|
||||
"Server memory": "服务器内存",
|
||||
"As of {time}": "截至 {time}",
|
||||
"Not recorded on this server": "此服务器未记录",
|
||||
"Last 24 hours": "过去 24 小时",
|
||||
"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": "自定义角色",
|
||||
|
||||
@@ -1745,6 +1745,25 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.admin-dns-type { flex: 0 0 48px; font-family: var(--font-mono); font-size: .78em; font-weight: 700; color: var(--accent-soft-fg); padding-top: 2px; }
|
||||
.admin-dns-name { font-size: .88em; font-weight: 550; word-break: break-all; }
|
||||
.admin-dns-value { display: block; font-family: var(--font-mono); font-size: .8em; color: var(--fg-muted); word-break: break-all; margin-top: 2px; max-height: 4.8em; overflow: hidden; }
|
||||
/* Column counts come from balancedColumns, so rows stay even; the widths
|
||||
follow the space the grid actually has, not the window, since the folder
|
||||
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; } }
|
||||
.admin-card { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-elev); color: var(--fg); text-decoration: none; }
|
||||
.admin-card.link:hover { background: var(--bg-hover); }
|
||||
.admin-card.link:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.admin-card-top { display: flex; align-items: center; gap: 8px; color: var(--fg-muted); }
|
||||
.admin-card-icon { display: inline-flex; color: var(--accent-soft-fg); }
|
||||
.admin-card-label { font-size: .85em; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.admin-card-value { font-size: 1.9em; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; min-height: 1.15em; }
|
||||
.admin-card-placeholder { display: inline-block; width: 3.5ch; height: .9em; border-radius: var(--radius-sm); background: var(--bg-sunken); vertical-align: middle; }
|
||||
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
|
||||
.admin-kv dt { color: var(--fg-muted); }
|
||||
.admin-kv dd { margin: 0; }
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link } from "wouter";
|
||||
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). */
|
||||
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 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 cancelled = false;
|
||||
const into = <T,>(set: (v: Loaded<T>) => void, work: () => Promise<T>) => {
|
||||
set(LOADING);
|
||||
void settle(work()).then((v) => !cancelled && 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 () => summariseMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
|
||||
return () => {
|
||||
cancelled = 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>
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, User } from "lucide-react";
|
||||
import { Globe, LayoutDashboard, User } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
|
||||
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { ReactNode } from "react";
|
||||
import { Redirect } from "wouter";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { AccountsAdmin } from "./AccountsAdmin";
|
||||
import { AdminDashboard } from "./AdminDashboard";
|
||||
import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
dashboard: () => <AdminDashboard />,
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
@@ -16,8 +18,8 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
*
|
||||
* The page is only the open section. Its list of sections is in the folder
|
||||
* pane (see AdminNav), so the tables here get the width Settings spends on a
|
||||
* second column. A section the role cannot read -- typed into the address bar,
|
||||
* say -- opens the first one it can.
|
||||
* second column. A bare /admin opens the dashboard, and a section the role
|
||||
* cannot read -- typed into the address bar, say -- opens the first one it can.
|
||||
*/
|
||||
export function AdminView({ section, id }: { section?: string; id?: string }) {
|
||||
const allowed = adminSections(usePermissions());
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Router } from "wouter";
|
||||
import { memoryLocation } from "wouter/memory-location";
|
||||
import { JmapMethodError } from "@/jmap/client";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import type { MetricRecord } from "@/lib/adminDashboard";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const feeds = vi.hoisted(() => ({
|
||||
counts: { Account: 35, Domain: 3, QueuedMessage: 9 } as Record<string, number>,
|
||||
metrics: (): Promise<MetricRecord[]> => Promise.resolve([]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/adminDashboard", async (original) => ({
|
||||
...(await original<typeof import("@/lib/adminDashboard")>()),
|
||||
countObjects: vi.fn(async (object: string) => feeds.counts[object]),
|
||||
loadMetrics: vi.fn(() => feeds.metrics()),
|
||||
}));
|
||||
|
||||
const { AdminDashboard } = await import("../AdminDashboard");
|
||||
|
||||
const signIn = (permissions: string[]) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||
|
||||
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
|
||||
const TENANT = [...HELPDESK, "sysAccountCreate", "sysDomainCreate", "sysQueuedMessageGet", "sysQueuedMessageQuery"];
|
||||
const ADMIN = [...TENANT, "sysMetricGet", "sysMetricQuery"];
|
||||
|
||||
/** The dashboard shows what the role may read, and nothing a server refuses. */
|
||||
describe("the Administration dashboard", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async () => {
|
||||
const { hook } = memoryLocation({ path: "/admin" });
|
||||
await act(async () => {
|
||||
root.render(<Router hook={hook}><AdminDashboard /></Router>);
|
||||
});
|
||||
await act(async () => {});
|
||||
};
|
||||
const cards = () => [...host.querySelectorAll(".admin-card")].map((c) => [c.querySelector(".admin-card-label")?.textContent, c.querySelector(".admin-card-value")?.textContent, c.querySelector(".hint")?.textContent]);
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
feeds.metrics = () => Promise.resolve([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("gives a helpdesk role its two counts and nothing about the server", async () => {
|
||||
signIn(HELPDESK);
|
||||
await render();
|
||||
expect(cards()).toEqual([
|
||||
["Users", "35", "User accounts"],
|
||||
["Domains", "3", "Mail domains"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a tenant administrator the queue as well, but no history", async () => {
|
||||
signIn(TENANT);
|
||||
await render();
|
||||
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
|
||||
});
|
||||
|
||||
it("adds the history for a role that reads metrics", async () => {
|
||||
feeds.metrics = () =>
|
||||
Promise.resolve([
|
||||
{ "@type": "Gauge", metric: "server.memory", count: 360_000_000, timestamp: "2026-09-15T15:00:00Z" },
|
||||
{ "@type": "Counter", metric: "queue.message-queued", count: 93, timestamp: "2026-09-15T15:00:00Z" },
|
||||
{ "@type": "Counter", metric: "queue.report-queued", count: 39, timestamp: "2026-09-15T15:00:00Z" },
|
||||
]);
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().map((c) => [c[0], c[1]])).toEqual([
|
||||
["Users", "35"],
|
||||
["Domains", "3"],
|
||||
["Pending", "9"],
|
||||
["Server memory", "343 MB"],
|
||||
["Received", "93"],
|
||||
["Sent", "39"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the history off where the server refuses it, as Community does", async () => {
|
||||
feeds.metrics = () => Promise.reject(new JmapMethodError("x:Metric/query", { type: "forbidden", description: "This feature is only available in the Enterprise edition" }));
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
|
||||
});
|
||||
|
||||
it("says the history is not recorded rather than showing a quiet day", async () => {
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
expect(cards().slice(3)).toEqual([
|
||||
["Server memory", "—", "Not recorded on this server"],
|
||||
["Received", "—", "Not recorded on this server"],
|
||||
["Sent", "—", "Not recorded on this server"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a card that failed for another reason, and says so", async () => {
|
||||
feeds.metrics = () => Promise.reject(new Error("offline"));
|
||||
signIn(ADMIN);
|
||||
await render();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -35,20 +35,26 @@ describe("the Administration list in the folder pane", () => {
|
||||
it("lists each readable section under its group and marks the open one", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin/domains/d1");
|
||||
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Directory", "Mail"]);
|
||||
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Overview", "Directory", "Mail"]);
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
});
|
||||
|
||||
it("treats a bare /admin as the first section, which is what the page opens", async () => {
|
||||
it("treats a bare /admin as the dashboard, which is what the page opens", async () => {
|
||||
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Accounts");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
|
||||
});
|
||||
|
||||
it("leaves out what the role cannot read", async () => {
|
||||
signIn(["sysDomainQuery", "sysDomainGet"]);
|
||||
await render("/admin");
|
||||
await render("/admin/accounts");
|
||||
expect(host.textContent).not.toContain("Accounts");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
|
||||
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
|
||||
});
|
||||
|
||||
it("offers the dashboard alone to a role that can only count", async () => {
|
||||
signIn(["sysQueuedMessageQuery"]);
|
||||
await render("/admin");
|
||||
expect([...host.querySelectorAll(".nav-item")].map((e) => e.textContent)).toEqual(["Dashboard"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user