Merge pull request #334 from Coffey-Labs/feat/admin-accounts

Add Administration, starting with accounts
This commit is contained in:
jcoffey
2026-09-13 15:46:46 -07:00
committed by GitHub
38 changed files with 2831 additions and 23 deletions
+6
View File
@@ -61,6 +61,12 @@ MAX_UPLOAD_BYTES=52428800
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly. # Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
IMAGE_PROXY=1 IMAGE_PROXY=1
# In-app administration, for accounts whose Stalwart role manages accounts and
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
# Stalwart's registry methods beyond an account's own password, app passwords
# and settings. Stalwart's own admin interface is not affected.
ADMINISTRATION=1
# Branding # Branding
APP_NAME=ihasmail APP_NAME=ihasmail
+79
View File
@@ -1093,6 +1093,78 @@ needed nothing in either half.
--- ---
# Administration
An account whose Stalwart role manages other accounts finds **Administration**
in the account menu, top right. Nobody else sees the entry, and the page
redirects them to their mail if they type its address in.
## What it offers is what the role allows
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 both query and read accounts (`sysAccountQuery`, `sysAccountGet`),
and each 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
what they can do.
None of that is the security boundary. Every read and write is a JMAP `x:`
call through the ordinary `/api/jmap` proxy, authenticated as the signed-in
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.
## Accounts
- **List and search** by name or address, fifty to a page, newest first — the
server's own order. Role, storage used against the limit, and groups at a
glance.
- **Create** an account on any domain the role can see: display name, address,
a generated password to copy and pass on, role, and storage limit.
- **Edit** the display name, other addresses (aliases), role and storage limit.
One save sends only what changed.
- **Set a new password.** It goes into the account's existing password
credential, and signs the person out of every app and device using the old
one, because Stalwart ties every token to the password.
- **Delete**, after typing the address to confirm. Stalwart removes the
mailbox's data in the background, and says so.
Roles are offered only when the viewer holds every permission they carry,
which is the check Stalwart makes on a grant. It does **not** make that check
when only a password changes, or on a delete, so an account allowed to edit
accounts could otherwise reset the password of one that can do more and sign
in as it. ihasmail shows any account that outranks the viewer read-only, and
counts a role it cannot read as outranking rather than not. Nobody can change
their own role or delete the account they are signed in with.
## An operator can turn it off
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
menu. The permissions are no longer sent to the browser, and the JMAP proxy
refuses Stalwart registry methods except the ones about the signed-in account
itself — its password, app passwords, API keys, public keys, masked addresses
and account settings. Without that, hiding the menu would leave an
administrator's browser console able to make every call the menu made.
Stalwart's own interface is unaffected; this decides what ihasmail offers.
## Stateless, as everything else
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
no database and no cache beyond the permissions list that rides along with the
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 is the first section. Groups, mailing lists, roles, domains (with
their DNS records and DKIM keys) and tenants are Stalwart capabilities the same
screen is laid out to take; reporting, queues, logs and server settings are
deliberately out of scope.
---
# Live updates and notifications # Live updates and notifications
- **JMAP push over EventSource**, proxied by ihasmail's server so the browser - **JMAP push over EventSource**, proxied by ihasmail's server so the browser
@@ -1459,6 +1531,7 @@ wizard, because either would be state.
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds | | `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB | | `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images | | `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu |
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window | | `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
| `COOKIE_NAME` | `ihm_session` | | | `COOKIE_NAME` | `ihm_session` | |
| `APP_NAME` | `ihasmail` | Branding | | `APP_NAME` | `ihasmail` | Branding |
@@ -1544,6 +1617,12 @@ moves an occurrence renumbering the ids around it. Two switches:
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be `MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
tested. tested.
Administration works against it too, with a directory of about thirty accounts
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.
--- ---
# What it does not do # What it does not do
+2
View File
@@ -32,6 +32,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged 0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support). [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **Administration has not yet been exercised against a live Stalwart.** It was built on 2026-09-13 against the 0.16.22 source and the mock, which reproduces the shapes read there — lists as index-keyed objects, sets as `{"id": true}`, masked secrets, AND-only filters — and it has not touched a real server. Four things are read from source rather than proved: that `/api/account` lists permissions in camelCase (`sysAccountGet`) as the enum serialises them, where the documentation shows kebab-case — both are accepted, so the menu works either way; that a new password written to `credentials/<index>/secret` is hashed and keeps the credential's id; that the Basic credential ihasmail proxies with reaches the admin `x:` methods as it already reaches the self-service ones; and that Stalwart skips its grant check when only a password changes, which is the reason the outranking guard exists at all. The last is worth reproducing rather than trusting in either direction. Query and get are sent as two requests rather than one with a back-reference, because whether the registry methods resolve references was not checked.
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. - **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. - **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
+1
View File
@@ -8,6 +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. See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **Administration beyond accounts.** The Administration menu manages accounts today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, domains with their DNS records and DKIM keys, DNS providers and tenants are all Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent. 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.
- **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. - **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. - **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. - **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.
+3 -3
View File
@@ -33,8 +33,8 @@ test("an account with no locale set yields none, rather than a guess", () => {
}); });
test("neither answering leaves the locale unknown", () => { test("neither answering leaves the locale unknown", () => {
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null }); assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null }); assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
}); });
test("locales that carry no language are dropped, not passed through", () => { test("locales that carry no language are dropped, not passed through", () => {
@@ -48,7 +48,7 @@ test("a server without the registry is not asked for anything", async () => {
// fails the whole request rather than the one call. // fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} }; const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never); const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null }); assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
}); });
test("no capabilities at all is treated the same way", async () => { test("no capabilities at all is treated the same way", async () => {
+40
View File
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { gateAdministration } from "./adminGate.js";
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
/**
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
* operator's decision. Hiding the menu would leave the proxy forwarding the
* very calls the menu made.
*/
test("mail, calendars and the rest pass untouched", () => {
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
assert.equal(r.ok, true);
});
test("the account's own registry objects pass", () => {
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
});
test("directory and server objects are refused, and named", () => {
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
}
});
test("a body that cannot be read is refused rather than forwarded unchecked", () => {
assert.deepEqual(gateAdministration("{not json"), { ok: false, method: null });
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]] })), { ok: false, method: null });
});
test("what is forwarded is what was checked", () => {
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
// means the server cannot read it the other way.
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
const r = gateAdministration(raw);
assert.equal(r.ok, true);
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
});
+47
View File
@@ -0,0 +1,47 @@
/**
* What the JMAP proxy lets through when an operator has turned in-app
* administration off (`ADMINISTRATION=0`).
*
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
* browser sends, and Stalwart's registry answers whatever the credential's role
* allows -- so without this, an administrator could still manage accounts, or
* the whole server, from the browser console of an installation whose operator
* said no. With it off, the proxy refuses every `x:` method except the few that
* are about the signed-in account itself.
*
* An allowlist rather than a list of administrative objects, because the
* registry has dozens of them -- listeners, stores, tracers, system settings --
* and a new release adds more. An object not named here is refused, which errs
* towards the operator's decision.
*
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
* touched: they act on what the account can already reach.
*/
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
/**
* Check a JMAP request body. On success, hands back the body to forward --
* serialised from what was inspected, so the server can never be sent
* something different from what was checked (a duplicate key, say, read one
* way here and another way there).
*/
export function gateAdministration(raw: string): GateResult {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, method: null };
}
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
if (!Array.isArray(calls)) return { ok: false, method: null };
for (const call of calls) {
const name = Array.isArray(call) ? call[0] : undefined;
if (typeof name !== "string") return { ok: false, method: null };
if (!name.startsWith("x:")) continue;
const object = name.slice(2).split("/")[0] ?? "";
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
}
return { ok: true, body: JSON.stringify(parsed) };
}
+39 -2
View File
@@ -8,6 +8,7 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js"; import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
import { getConnInfo } from "@hono/node-server/conninfo"; import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js"; import { config } from "./config.js";
import { gateAdministration } from "./adminGate.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js"; import { RateLimiter } from "./ratelimit.js";
import { resolveClientIp } from "./clientip.js"; import { resolveClientIp } from "./clientip.js";
@@ -637,6 +638,28 @@ export function createApp(basePath = config.basePath): Hono<Env> {
if (!ct.toLowerCase().startsWith("application/json")) { if (!ct.toLowerCase().startsWith("application/json")) {
return c.json({ error: "unsupported_media_type" }, 415); return c.json({ error: "unsupported_media_type" }, 415);
} }
/*
* With administration switched off the body is read and checked before it
* goes anywhere; with it on, it streams straight through as it always has,
* so an installation that allows administration pays nothing for this.
*/
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
if (!config.administration) {
let raw: string;
try {
// Counted as it arrives: a chunked body carries no length to refuse up front.
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
} catch {
return c.json({ error: "too_large" }, 413);
}
const gate = gateAdministration(raw);
if (!gate.ok) {
return gate.method
? c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403)
: c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
}
body = gate.body;
}
try { try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
@@ -646,7 +669,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
"content-type": "application/json", "content-type": "application/json",
accept: "application/json", accept: "application/json",
}, },
body: c.req.raw.body, body,
duplex: "half", duplex: "half",
signal: AbortSignal.timeout(config.upstreamTimeout), signal: AbortSignal.timeout(config.upstreamTimeout),
}); });
@@ -828,7 +851,7 @@ function appPasswordName(c: Context): string {
return `${config.appName} (${browser})`; return `${config.appName} (${browser})`;
} }
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) { function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null, permissions: [] }) {
return { return {
ihasmail: { ihasmail: {
appName: config.appName, appName: config.appName,
@@ -842,6 +865,14 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
userLocale: info.locale, userLocale: info.locale,
/** What the upstream server would tell us about itself. */ /** What the upstream server would tell us about itself. */
server: { edition: info.edition }, server: { edition: info.edition },
/** Whether this installation offers administration at all (ADMINISTRATION). */
administration: config.administration,
/**
* The account's permissions on that server, so the client can offer
* administration to those who have it. Stalwart still decides every call.
* Withheld when administration is off: nothing in the browser needs them.
*/
permissions: config.administration ? info.permissions : [],
}, },
}; };
} }
@@ -851,6 +882,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
* denylist: everything else it might set — cookies, auth challenges, CORS * denylist: everything else it might set — cookies, auth challenges, CORS
* grants — would be landing on *our* origin, where it means something else. * grants — would be landing on *our* origin, where it means something else.
*/ */
/**
* The largest JMAP request read into memory for the administration check.
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
*/
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]); const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
/** /**
+7
View File
@@ -295,6 +295,13 @@ export const config = {
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000), upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024), maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
imageProxy: bool("IMAGE_PROXY", true), imageProxy: bool("IMAGE_PROXY", true),
/*
* Whether ihasmail offers administration to accounts whose Stalwart role
* allows it. Off means off: no menu, no permissions sent to the browser, and
* the JMAP proxy refuses registry methods beyond the account's own -- see
* adminGate.ts. Stalwart's own interface is unaffected either way.
*/
administration: bool("ADMINISTRATION", true),
cookieName: env("COOKIE_NAME", "ihm_session"), cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)), staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10), loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createDirectory, permissionsFor, type MockRole } from "./directory.js";
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) });
/**
* The mock stands in for a server that decides what each account may do, so
* the client's administration can be developed against refusals as well as
* successes. These pin the refusals.
*/
test("an ordinary user is refused the directory outright", () => {
const dir = make("user");
assert.throws(() => dir.handlers["x:Account/query"]!({}), (e: Refused) => e.type === "forbidden");
assert.ok(!permissionsFor("user").some((p) => p.startsWith("sysAccountQuery")));
});
test("helpdesk may read and edit but not create or delete", () => {
const dir = make("helpdesk");
const { ids } = dir.handlers["x:Account/query"]!({ filter: { type: "User" } }) as { ids: string[] };
assert.ok(ids.length > 20);
assert.throws(() => dir.handlers["x:Account/set"]!({ create: { n: { name: "x", domainId: "d1" } } }), (e: Refused) => e.type === "forbidden");
assert.throws(() => dir.handlers["x:Account/set"]!({ destroy: [ids[0]] }), (e: Refused) => e.type === "forbidden");
});
test("queries page, count and match text the way the client asks", () => {
const dir = make("admin");
const all = dir.handlers["x:Account/query"]!({ filter: { type: "User" }, calculateTotal: true }) as { ids: string[]; total: number };
const page = dir.handlers["x:Account/query"]!({ filter: { type: "User" }, position: 10, limit: 5, calculateTotal: true }) as { ids: string[]; total: number };
assert.equal(page.total, all.total);
assert.deepEqual(page.ids, all.ids.slice(10, 15));
const ada = dir.handlers["x:Account/query"]!({ filter: { type: "User", text: "lovelace" } }) as { ids: string[] };
assert.equal(ada.ids.length, 1);
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { operator: "OR", conditions: [] } }), (e: Refused) => e.type === "unsupportedFilter");
});
test("an address already used as an alias cannot be taken", () => {
const dir = make("admin");
const res = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "User", name: "postmaster", domainId: "d1", credentials: { "0": { "@type": "Password", secret: "long enough secret" } }, roles: { "@type": "User" } } } }) as { notCreated?: Record<string, { type: string }> };
assert.equal(res.notCreated?.n?.type, "primaryKeyViolation");
});
test("a password is set through its credential's pointer, and a weak one is refused", () => {
const dir = make("admin");
const set = dir.handlers["x:Account/set"]!;
assert.equal((set({ update: { a1: { "credentials/0/secret": "short" } } }) as { notUpdated?: Record<string, { properties: string[] }> }).notUpdated?.a1?.properties[0], "secret");
assert.deepEqual((set({ update: { a1: { "credentials/0/secret": "a much longer secret" } } }) as { updated: object }).updated, { a1: null });
const got = dir.handlers["x:Account/get"]!({ ids: ["a1"], properties: ["credentials"] }) as { list: Array<{ credentials: Record<string, { secret: string }> }> };
assert.equal(got.list[0]!.credentials["0"]!.secret, "[********]", "never echoed back");
});
test("a grant the caller does not hold is refused", () => {
const dir = make("helpdesk");
const res = dir.handlers["x:Account/set"]!({ update: { u101: { roles: { "@type": "Admin" } } } }) as { notUpdated?: Record<string, { type: string }> };
assert.equal(res.notUpdated?.u101?.type, "forbidden");
});
test("an administrator can delete an account, and a group with members is kept", () => {
const dir = make("admin");
const set = dir.handlers["x:Account/set"]!;
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
assert.equal((set({ destroy: ["g1"] }) as { notDestroyed?: Record<string, { type: string }> }).notDestroyed?.g1?.type, "objectIsLinked");
});
+306
View File
@@ -0,0 +1,306 @@
/**
* Enough of Stalwart 0.16's directory registry to develop administration
* against: `x:Account`, `x:Domain` and `x:Role`, gated by permission names the
* way the real server gates them.
*
* Shapes follow the 0.16.22 source rather than the documentation, which has
* been wrong about both before:
*
* - a `List<T>` (credentials, aliases) is an object keyed by index -- `{"0": …}`
* -- and a `Set` (memberGroupIds, enabledPermissions) is `{"id": true}`;
* - an account's `name` is the local part only, and it lives on a domain by id;
* - secrets come back masked, and a new one is written through the password
* credential's own pointer, `credentials/<index>/secret`;
* - `x:Account/query` understands AND and nothing else.
*
* 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.
*
* MOCK_ROLE picks 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`.
*/
type Obj = Record<string, unknown>;
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}`));
/** 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"];
case "tenant-admin":
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
case "helpdesk":
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
default:
return USER_PERMISSIONS;
}
}
export function mockRole(raw: string | undefined): MockRole {
return raw === "tenant-admin" || raw === "helpdesk" || raw === "user" ? raw : "admin";
}
const MASKED = "[********]";
const GIB = 1024 ** 3;
interface Options {
/** The demo user's JMAP account id, which is also its registry id. */
accountId: string;
/** The demo user's address. */
user: string;
locale: string;
role: MockRole;
/** Build the error a method fails with; the mock server owns the type. */
fail: (type: string, description?: string) => Error;
}
export function createDirectory(opts: Options) {
const permissions = new Set(permissionsFor(opts.role));
const [userLocal, userDomain] = splitAddress(opts.user);
let counter = 100;
const domains: Obj[] = [
{ id: "d1", name: userDomain, aliases: {}, description: null },
{ id: "d2", name: userDomain === "example.org" ? "example.net" : "example.org", aliases: {}, description: null },
];
const roles: Obj[] = [
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
];
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
const accounts: Obj[] = [];
const user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean }) => {
const domainId = o.domain === "d2" ? "d2" : "d1";
const row: Obj = {
id: o.id ?? `u${counter++}`,
"@type": "User",
name: o.name,
domainId,
description: o.description,
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
memberGroupIds: flags(o.groups ?? []),
memberTenantId: null,
roles: o.roles ?? { "@type": "User" },
permissions: { "@type": "Inherit" },
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
usedDiskQuota: Math.round((o.used ?? 0) * GIB),
aliases: Object.fromEntries((o.aliases ?? []).map((name, i) => [String(i), { enabled: true, name, domainId, description: null }])),
locale: opts.locale,
timeZone: null,
};
accounts.push(row);
return row;
};
const group = (id: string, name: string, description: string) =>
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "User" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {} });
group("g1", "support", "Support");
group("g2", "office", "Office");
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
user({ name: "ada", domain: "d2", description: "Ada Lovelace", used: 3.2, quota: 5, groups: ["g2"] });
user({ name: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
user({ name: "margaret", description: "Margaret Hamilton", roles: { "@type": "Admin" }, used: 2.1, quota: 20 });
user({ name: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
user({ name: "sso.only", description: "Signs in with SSO", password: false, used: 0.1 });
const people = ["Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "John Backus", "Radia Perlman", "Ken Thompson", "Hedy Lamarr", "Dennis Ritchie", "Karen Spärck Jones", "Tim Berners-Lee", "Sophie Wilson", "Niklaus Wirth", "Jean Sammet", "Leslie Lamport", "Mary Kenneth Keller", "Tony Hoare", "Evelyn Berezin", "Butler Lampson", "Shafi Goldwasser", "Whitfield Diffie", "Adele Goldberg", "Vint Cerf", "Anita Borg", "Bob Kahn", "Lynn Conway", "Charles Babbage", "Annie Easley"];
people.forEach((description, i) => {
const name = description.toLowerCase().split(" ")[0]!.normalize("NFD").replace(/[^a-z]/g, "");
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
});
const demand = (perm: string) => {
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
};
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
/** Every address in use, primary and alias, across accounts. */
const addressTaken = (address: string, except?: string) =>
accounts.some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
const view = (o: Obj, properties: unknown): Obj => {
const full: Obj = { ...o, emailAddress: addressOf(o) };
if (full.credentials) {
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
}
if (!Array.isArray(properties)) return full;
const out: Obj = { id: o.id };
for (const p of properties as string[]) if (p in full) out[p] = full[p];
return out;
};
const get = (list: Obj[], perm: string) => (a: Obj) => {
demand(perm);
const ids = a.ids as string[] | null | undefined;
const found = ids ? list.filter((x) => ids.includes(x.id as string)) : list;
return { accountId: opts.accountId, state: "1", list: found.map((x) => view(x, a.properties)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
const query = (list: () => Obj[], perm: string, match: (o: Obj, filter: Obj) => boolean) => (a: Obj) => {
demand(perm);
const filter = (a.filter as Obj | undefined) ?? {};
if ("operator" in filter) throw opts.fail("unsupportedFilter", "Only AND is supported in filters");
// Stalwart's default order is newest first, by id.
const rows = list().filter((o) => match(o, filter)).sort((x, y) => String(y.id).localeCompare(String(x.id), undefined, { numeric: true }));
const position = Math.max(0, Number(a.position ?? 0));
const limit = a.limit == null ? rows.length : Number(a.limit);
return {
accountId: opts.accountId,
queryState: "1",
canCalculateChanges: false,
position,
ids: rows.slice(position, position + limit).map((o) => o.id),
...(a.calculateTotal ? { total: rows.length } : {}),
};
};
const matchText = (o: Obj, text: unknown) => {
if (typeof text !== "string" || !text.trim()) return true;
const needle = text.trim().toLowerCase();
return [o.name, o.description, addressOf(o)].some((v) => typeof v === "string" && v.toLowerCase().includes(needle));
};
const setError = (type: string, description: string, properties?: string[]) => ({ type, description, ...(properties ? { properties } : {}) });
/** The password checks, roughly as strict as a default Stalwart. */
const weakPassword = (secret: unknown) => (typeof secret !== "string" || secret.length < 8 ? "Password must be at least 8 characters long." : null);
/** Stalwart checks a grant against the caller's own permissions. */
const grantRefused = (roles: unknown): string | null => {
const r = roles as Obj | undefined;
if (!r) return null;
if (r["@type"] === "Admin" && opts.role !== "admin" && opts.role !== "tenant-admin") return "You are not authorized to grant permissions: administrator.";
if (r["@type"] === "Custom") {
for (const id of Object.keys((r.roleIds as Obj) ?? {})) {
const role = roles_(id);
if (!role) return "Role does not exist.";
const missing = Object.keys((role.enabledPermissions as Obj) ?? {}).filter((p) => !permissions.has(p));
if (missing.length) return `You are not authorized to grant permissions: ${missing.join(", ")}.`;
}
}
return null;
};
const roles_ = (id: string) => roles.find((r) => r.id === id);
const handlers: Record<string, (a: Obj) => Obj> = {
"x:Account/get": get(accounts, "sysAccountGet"),
"x:Account/query": query(() => accounts, "sysAccountQuery", (o, f) =>
(f.type === undefined || o["@type"] === f.type) && (f.domainId === undefined || o.domainId === f.domainId) && matchText(o, f.text) && matchText(o, f.name)),
"x:Account/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
const notDestroyed: Obj = {};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
demand("sysAccountCreate");
const o = { ...(raw as Obj) };
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) { notCreated[cid] = setError("invalidProperties", "Invalid account name.", ["name"]); continue; }
if (!domainName(o.domainId)) { notCreated[cid] = setError("invalidForeignKey", "Domain does not exist.", ["domainId"]); continue; }
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); continue; }
const refused = grantRefused(o.roles);
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
const weak = password ? weakPassword(password.secret) : null;
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
const id = `u${counter++}`;
accounts.push({ memberGroupIds: {}, aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
demand("sysAccountUpdate");
const target = accounts.find((x) => x.id === id);
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
const patch = raw as Obj;
const next = structuredClone(target);
let failure: Obj | null = null;
for (const [path, value] of Object.entries(patch)) {
if (path === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
if (path.endsWith("/secret")) {
const weak = weakPassword(value);
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
}
if (path.startsWith("credentials/") && value && typeof value === "object") {
const weak = weakPassword((value as Obj).secret);
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
}
setPointer(next, path, value);
}
if (!failure && ("roles" in patch || "permissions" in patch)) {
const refused = grantRefused(next.roles);
if (refused) failure = setError("forbidden", refused);
}
if (!failure) {
for (const al of Object.values((next.aliases as Obj) ?? {})) {
const address = `${(al as Obj).name}@${domainName((al as Obj).domainId)}`;
if (!domainName((al as Obj).domainId)) { failure = setError("invalidForeignKey", "Domain does not exist.", ["aliases"]); break; }
if (addressTaken(address, id)) { failure = setError("primaryKeyViolation", "An account or alias with this email address already exists."); break; }
}
}
if (failure) { notUpdated[id] = failure; continue; }
// Secrets are stored hashed; the mock just stops echoing them.
for (const c of Object.values((next.credentials as Obj) ?? {})) (c as Obj).secret = MASKED;
Object.assign(target, next);
updated[id] = null;
}
for (const id of (a.destroy as string[]) ?? []) {
demand("sysAccountDestroy");
const i = accounts.findIndex((x) => x.id === id);
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
notDestroyed[id] = { ...setError("objectIsLinked", "Group still has members."), linkedObjects: {} };
continue;
}
accounts.splice(i, 1);
destroyed.push(id);
}
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
},
"x:Domain/get": get(domains, "sysDomainGet"),
"x:Domain/query": query(() => domains, "sysDomainQuery", (o, f) => matchText(o, f.text) && matchText(o, f.name)),
"x:Role/get": get(roles, "sysRoleGet"),
"x:Role/query": query(() => roles, "sysRoleQuery", (o, f) => matchText(o, f.description)),
};
return { handlers, permissions: [...permissions], accounts };
}
function flags(names: string[]): Obj {
return Object.fromEntries(names.map((n) => [n, true]));
}
function splitAddress(address: string): [string, string] {
const at = address.lastIndexOf("@");
return at < 0 ? [address, "example.com"] : [address.slice(0, at), address.slice(at + 1)];
}
/**
* Apply one JMAP patch entry. A path walks into nested objects; `null` at the
* end removes the key, which is how an alias or a quota is taken away.
*/
function setPointer(obj: Obj, path: string, value: unknown): void {
const parts = path.split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
let node = obj;
for (const part of parts.slice(0, -1)) {
if (!node[part] || typeof node[part] !== "object") node[part] = {};
node = node[part] as Obj;
}
const last = parts[parts.length - 1]!;
if (value === null) delete node[last];
else node[last] = value;
}
+15 -7
View File
@@ -9,6 +9,7 @@ import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { createDirectory, mockRole } from "./directory.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788); const PORT = Number(process.env.MOCK_PORT ?? 8788);
/** /**
@@ -885,6 +886,15 @@ function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
return true; return true;
} }
/** Who the demo user is, for administration. See mock/directory.ts. */
const directory = createDirectory({
accountId: ACCOUNT,
user: USER,
locale: MOCK_LOCALE,
role: mockRole(process.env.MOCK_ROLE),
fail: (type, description) => new MethodError(type, description),
});
const handlers: Record<string, Handler> = { const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users // 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet). // actually have (unlike x:Account below, which needs sysAccountGet).
@@ -893,12 +903,10 @@ const handlers: Record<string, Handler> = {
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null })); const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") }; return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
}, },
// Stalwart's directory extension - the client reads the account locale from here. // Stalwart's directory registry: accounts, domains and roles, behind the
"x:Account/get": (a) => { // same permissions as the real thing. The locale fallback reads x:Account
const ids = (a.ids as string[] | null) ?? [ACCOUNT]; // too, and is refused here exactly when a real server would refuse it.
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null })); ...directory.handlers,
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
},
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never, "Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; }, "Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), "Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
@@ -1413,7 +1421,7 @@ export const server = createServer(async (req, res) => {
// The account info endpoint; the only place a server reports its edition. // The account info endpoint; the only place a server reports its edition.
if (url.pathname === "/api/account" && req.method === "GET") { if (url.pathname === "/api/account" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE })); return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
} }
if (url.pathname === "/jmap/" && req.method === "POST") { if (url.pathname === "/jmap/" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] }; const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
+27
View File
@@ -0,0 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { interpretServerAccount, normalizePermission } from "./upstream.js";
/**
* `/api/account` is the only place Stalwart lists what an account may do, and
* ihasmail used to read the edition out of it and throw the rest away.
*/
test("the account's permissions are kept alongside the edition", () => {
const info = interpretServerAccount({ edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"], locale: "en_US" });
assert.deepEqual(info, { edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"] });
});
test("permission names read the same whichever case the server uses", () => {
// The source serialises camelCase; the documentation shows kebab-case.
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
assert.deepEqual(interpretServerAccount({ permissions: ["sys-account-get", "sysAccountGet"] }).permissions, ["sysAccountGet"]);
});
test("a body without a usable list yields no permissions rather than failing", () => {
assert.deepEqual(interpretServerAccount({ edition: "oss" }), { edition: "oss", permissions: [] });
assert.deepEqual(interpretServerAccount({ permissions: "sysAccountGet" }), { edition: null, permissions: [] });
assert.deepEqual(interpretServerAccount({ permissions: [1, null, "sysDomainGet"] }).permissions, ["sysDomainGet"]);
assert.deepEqual(interpretServerAccount(null), { edition: null, permissions: [] });
});
+41 -10
View File
@@ -132,11 +132,21 @@ export interface AccountInfo {
locale: string | null; locale: string | null;
/** "oss" | "community" | "enterprise", where the server reports it. */ /** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null; edition: string | null;
/**
* The account's effective permissions, as Stalwart reports them for the
* credential in use. Empty when the server would not say.
*
* Carried to the browser so it can offer only what the account may do --
* administration above all. It is never a grant: Stalwart checks every call
* it is sent, and a list that is stale or wrong costs a refused request, not
* access.
*/
permissions: string[];
} }
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>(); const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000; const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, edition: null }; const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
/** /**
* glibc modifiers that name a script rather than a dialect or a currency: * glibc modifiers that name a script rather than a dialect or a currency:
@@ -228,7 +238,7 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo { export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
const settings = responses.find((r) => r[2] === "s"); const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a"); const account = responses.find((r) => r[2] === "a");
return { locale: localeOf(settings) ?? localeOf(account), edition: null }; return { locale: localeOf(settings) ?? localeOf(account), edition: null, permissions: [] };
} }
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null { function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
@@ -239,30 +249,51 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
} }
/** /**
* Which edition the server is running. Stalwart deliberately does not publish * Permission names in the form the source serialises them.
* its version number to clients, but 0.16 does report its edition here. *
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
* server settles which is true, both are read as the one form, so a check
* written against `sysAccountGet` holds either way.
*/ */
async function fetchEdition(authorization: string, base: string): Promise<string | null> { export function normalizePermission(name: string): string {
return name.includes("-") ? name.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase()) : name;
}
/**
* What the server says about the signed-in account: its edition and its
* effective permissions. Stalwart deliberately does not publish its version
* number to clients, but 0.16 reports both of these here.
*/
async function fetchServerAccount(authorization: string, base: string): Promise<Pick<AccountInfo, "edition" | "permissions">> {
try { try {
const res = await fetch(`${base}/api/account`, { const res = await fetch(`${base}/api/account`, {
headers: { authorization, accept: "application/json" }, headers: { authorization, accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout), signal: AbortSignal.timeout(config.upstreamTimeout),
}); });
if (!res.ok) return null; if (!res.ok) return { edition: null, permissions: [] };
const body = (await res.json()) as { edition?: unknown }; return interpretServerAccount(await res.json());
return typeof body.edition === "string" ? body.edition : null;
} catch { } catch {
return null; return { edition: null, permissions: [] };
} }
} }
export function interpretServerAccount(body: unknown): Pick<AccountInfo, "edition" | "permissions"> {
const b = (body ?? {}) as { edition?: unknown; permissions?: unknown };
const permissions = Array.isArray(b.permissions)
? [...new Set(b.permissions.filter((p): p is string => typeof p === "string").map(normalizePermission))]
: [];
return { edition: typeof b.edition === "string" ? b.edition : null, permissions };
}
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> { export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
const cached = infoCache.get(sessionId); const cached = infoCache.get(sessionId);
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info; if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
let info = EMPTY_INFO; let info = EMPTY_INFO;
try { try {
info = await fetchAccountInfo(authorization, session); info = await fetchAccountInfo(authorization, session);
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) }; info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
} catch { } catch {
/* all of this is a nicety - never fail the session over it */ /* all of this is a nicety - never fail the session over it */
} }
+3
View File
@@ -31,6 +31,8 @@ const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m)
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView }))); const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView }))); const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
// Only ever opened by the few who administer, so nobody else downloads it.
const AdminView = lazy(() => import("@/views/admin/AdminView").then((m) => ({ default: m.AdminView })));
export function App() { export function App() {
const status = useSession((s) => s.status); const status = useSession((s) => s.status);
@@ -305,6 +307,7 @@ function AuthedApp() {
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route> <Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route> <Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route> <Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
<Route path="/admin/:section?/:id?">{(p) => <AdminView section={p.section} id={p.id} />}</Route>
<Route path="/login"> <Route path="/login">
<Redirect to="/mail" /> <Redirect to="/mail" />
</Route> </Route>
+6
View File
@@ -19,6 +19,9 @@ export const CAP = {
websocket: "urn:ietf:params:jmap:websocket", websocket: "urn:ietf:params:jmap:websocket",
} as const; } as const;
/** Stalwart's own capability, which carries its `x:` registry methods. */
export const STALWART_CAP = "urn:stalwart:jmap";
export class JmapMethodError extends Error { export class JmapMethodError extends Error {
constructor( constructor(
public readonly method: string, public readonly method: string,
@@ -349,6 +352,9 @@ export class JmapClient {
/** Map method name prefix → required capability URNs. */ /** Map method name prefix → required capability URNs. */
function usingFor(method: string): string[] { function usingFor(method: string): string[] {
const type = method.split("/")[0] ?? ""; const type = method.split("/")[0] ?? "";
// Stalwart's registry: accounts, domains, credentials. Advertised per
// account rather than in the session, which supportedUsing() allows for.
if (type.startsWith("x:")) return [STALWART_CAP];
switch (type) { switch (type) {
case "Mailbox": case "Mailbox":
case "Thread": case "Thread":
+8
View File
@@ -39,6 +39,14 @@ export interface JmapSession {
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */ /** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null; edition?: string | null;
}; };
/** False when the operator has turned in-app administration off. */
administration?: boolean;
/**
* The account's effective permissions on that server, as Stalwart reports
* them. What the client offers is shaped by these; what is allowed is
* decided by Stalwart on every call.
*/
permissions?: string[];
}; };
} }
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { ADMIN_BASELINE, can, 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");
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
const roles = new Map<string, RoleDef>([
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
]);
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);
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
expect(hasAdministration(permissionSet(undefined))).toBe(false);
});
it("reads one permission per object and operation", () => {
expect(can(helpdesk, "Account", "Update")).toBe(true);
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
expect(can(helpdesk, "Domain", "Get")).toBe(false);
});
});
/**
* Stalwart checks a grant, but not a password change or a delete. Without this,
* anyone allowed to edit accounts could take over one that can do more.
*/
describe("an account that outranks the viewer", () => {
it("an ordinary user never does", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
expect(outranks(helpdesk, {}, null)).toBe(false);
});
it("an administrator does, unless the viewer is one too", () => {
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
});
it("a custom role does when it carries something the viewer lacks", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
});
it("a role that cannot be read counts against the target, not for it", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
});
it("extra permissions on the account itself are counted", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
// Replace ignores the roles entirely, so only what it lists matters.
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
});
it("survives a role that names itself", () => {
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
});
});
describe("granting a role", () => {
it("is offered only for roles whose every permission the viewer holds", () => {
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
expect(canGrantRole(everything, "missing", roles)).toBe(false);
});
});
describe("generated passwords", () => {
it("are four groups of five unambiguous characters", () => {
const p = generatePassword();
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
expect(p).not.toMatch(/[01lIO]/);
});
it("skip bytes that would favour the start of the alphabet", () => {
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
// the first 36 characters twice as often. Bytes of 220 and up are dropped
// and more are drawn, so a batch of nothing but those costs a draw.
let call = 0;
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
expect(call).toBe(2);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, quotasWithDisk } from "@/lib/adminDirectory";
describe("setting a password", () => {
it("writes into the existing password credential, keeping its place", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
});
it("adds one after the last index when the account has none", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
expect(hasPassword(account)).toBe(false);
});
});
describe("lists written back", () => {
it("re-index aliases the way the server stores a list", () => {
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
"0": { enabled: true, name: "b", domainId: "d1", description: null },
"1": { enabled: false, name: "c", domainId: "d2", description: null },
});
});
it("change the disk limit without touching the other quotas", () => {
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
expect(quotasWithDisk(undefined, 0)).toEqual({});
});
});
describe("explaining a refusal", () => {
it("says what a taken address means", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
});
it("keeps the server's own words for a password policy", () => {
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
});
it("handles a method-level refusal as well as a set error", () => {
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* What the signed-in account may administer, read from the permissions Stalwart
* reported for it at sign-in.
*
* None of this is a security boundary, and nothing here should read as one.
* Every administrative call is a JMAP `x:` method sent through the ordinary
* proxy, and Stalwart checks each of them against the credential making it --
* scoping a tenant administrator's queries to their own tenant, and refusing a
* write the account may not make. What this decides is only what the client
* *offers*: a menu that appears for the people it can do something for, and
* buttons that are there when pressing them would work.
*
* The one place it is more than presentation is `outranks`, which stands in
* for a check Stalwart does not make. See there.
*/
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
export type Permissions = ReadonlySet<string>;
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
return new Set(list ?? []);
}
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
return perms.has(`sys${object}${op}`);
}
/**
* Whether to offer Administration at all.
*
* Accounts are the only section so far, and a list that cannot be opened is
* not worth a menu entry, so it takes both halves of reading one.
*/
export function hasAdministration(perms: Permissions): boolean {
return can(perms, "Account", "Query") && can(perms, "Account", "Get");
}
/**
* What an administrator holds, at the least: Stalwart's built-in Tenant
* Administrator role, for the parts of it that manage people and domains.
* Anyone who has all of this can already do anything to the accounts an
* "Administrator" account could.
*/
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
);
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export type PermissionsMode =
| { "@type": "Inherit" }
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
export interface RoleDef {
id: string;
description?: string | null;
enabledPermissions?: Record<string, boolean>;
roleIds?: Record<string, boolean>;
}
/**
* Whether an account can do something the viewer cannot.
*
* Stalwart checks that a caller holds every permission they grant -- when
* roles or permissions change, and when an account is created. It does not
* check when only a password changes, and it does not check a delete. So an
* account allowed to edit accounts could reset the password of one with far
* more rights than its own and sign in as it. ihasmail refuses to offer that,
* and treats such an account as read-only.
*
* It errs towards refusing. A role that cannot be read -- the viewer lacks
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
* an unknown grant is not a grant the viewer can be shown to hold. What it
* cannot see is tenancy: an "Administrator" account is a tenant administrator
* inside a tenant and a server administrator outside one, and a tenant-scoped
* viewer is not told which it is looking at. It never sees the second kind,
* which is why comparing against the administrator baseline is enough there.
*/
export function outranks(
viewer: Permissions,
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
roles: ReadonlyMap<string, RoleDef> | null,
): boolean {
let granted = new Set<string>();
const kind = target.roles?.["@type"] ?? "User";
if (kind === "Admin") {
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
} else if (kind === "Custom") {
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
const resolved = resolveRoles(ids, roles);
if (!resolved) return true;
granted = resolved;
}
const mode = target.permissions;
if (mode && mode["@type"] !== "Inherit") {
const enabled = Object.keys(mode.enabledPermissions ?? {});
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
}
for (const p of granted) if (!viewer.has(p)) return true;
return false;
}
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
if (!ids.length) return new Set();
if (!roles) return null;
const out = new Set<string>();
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (seen.has(id)) return true;
seen.add(id);
const role = roles.get(id);
if (!role) return false;
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
return Object.keys(role.roleIds ?? {}).every(walk);
};
return ids.every(walk) ? out : null;
}
/** Whether the viewer could grant a role: they hold everything it carries. */
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
const granted = resolveRoles([roleId], roles);
return granted !== null && [...granted].every((p) => viewer.has(p));
}
/**
* A password to hand to somebody who will change it.
*
* Twenty characters from an alphabet without the ones people misread aloud
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
* equally likely rather than the first few of the alphabet slightly more.
*/
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
const out: string[] = [];
const limit = 256 - (256 % ALPHABET.length);
while (out.length < 20) {
for (const byte of random(32)) {
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
}
}
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
}
+246
View File
@@ -0,0 +1,246 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
/**
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
*
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
* domains and roles are registry objects now, read and written with `x:Account`,
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
* authenticated as the signed-in account, so ihasmail holds nothing new: no
* route of its own, no store, no cache beyond the component showing the list.
*
* Shapes, from the 0.16.22 source:
*
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
* - An account's `name` is its local part, and its domain is a `domainId`.
* `emailAddress` and `usedDiskQuota` are computed by the server.
* - Secrets read back masked. A new password is written to the existing
* password credential, so its id -- which OAuth tokens are tied to -- stays.
* - Filters are AND only, and the default order is newest first.
*
* Query and get are two requests rather than one with a result reference.
* Whether the registry methods resolve back-references has not been checked on
* a live server, and a list that loads a moment slower is a better failure than
* one that never loads.
*/
export interface EmailAlias {
enabled?: boolean;
name: string;
domainId: string;
description?: string | null;
}
export interface Credential {
"@type": "Password" | "AppPassword" | "ApiKey";
secret?: string;
description?: string;
}
export interface DirectoryAccount {
id: string;
"@type": "User" | "Group";
name: string;
domainId: string;
emailAddress?: string;
description?: string | null;
roles?: UserRoles;
permissions?: PermissionsMode;
quotas?: Record<string, number>;
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
memberGroupIds?: Record<string, boolean>;
credentials?: Record<string, Credential>;
createdAt?: string;
}
export interface DirectoryDomain {
id: string;
name: string;
}
const ACCOUNT_PROPERTIES = [
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
];
/** The one quota ihasmail edits; the others keep whatever they had. */
export const DISK_QUOTA = "maxDiskQuota";
/** An error with a SetError behind it, kept so the caller can explain it. */
export class DirectoryError extends Error {
constructor(
readonly type: string,
readonly description: string | undefined,
readonly properties: string[] = [],
) {
super(description ?? type);
this.name = "DirectoryError";
}
}
interface QueryResult {
ids: string[];
total?: number;
position?: number;
}
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
const filter: Record<string, unknown> = { type: opts.type };
if (opts.text?.trim()) filter.text = opts.text.trim();
const res = await client.call<QueryResult>("x:Account/query", {
filter,
position: opts.position ?? 0,
...(opts.limit ? { limit: opts.limit } : {}),
calculateTotal: true,
});
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
}
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
// In the order the query gave, which is the order the list is shown in.
const byId = new Map(res.list.map((a) => [a.id, a]));
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
}
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
return res.list;
}
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
export async function listGroups(): Promise<DirectoryAccount[]> {
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
if (!q.ids.length) return [];
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
return res.list;
}
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
const failure = Object.values(res[kind] ?? {})[0];
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
}
export interface NewAccount {
name: string;
domainId: string;
description: string;
password: string;
roles: UserRoles;
diskQuotaBytes: number | null;
}
export async function createAccount(input: NewAccount): Promise<string> {
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
create: {
n: {
"@type": "User",
name: input.name.trim(),
domainId: input.domainId,
description: input.description.trim() || null,
credentials: { "0": { "@type": "Password", secret: input.password } },
roles: input.roles,
permissions: { "@type": "Inherit" },
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
memberGroupIds: {},
// Required on create. Turning it on is one-way and not offered here.
encryptionAtRest: { "@type": "Disabled" },
},
},
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
return id;
}
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyAccount(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The patch that sets a new password.
*
* Into the existing password credential when there is one, which keeps its
* credential id; as a new credential after the last index when there is not --
* an account that has only ever signed in through a directory, say. An account
* holds one password at most, so adding a second is never the answer.
*/
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
const entries = Object.entries(account.credentials ?? {});
const existing = entries.find(([, c]) => c["@type"] === "Password");
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
return { [`credentials/${next}`]: { "@type": "Password", secret } };
}
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
}
/** Re-index a list of aliases the way the server stores them. */
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
}
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
const next = { ...(quotas ?? {}) };
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
else delete next[DISK_QUOTA];
return next;
}
/**
* Say what went wrong in terms of the person's own action.
*
* Stalwart's descriptions are often exact and occasionally all there is -- a
* password policy says what it wants, in English -- so a description is kept
* where it carries something the type does not.
*/
export function describeDirectoryError(err: unknown): string {
if (!(err instanceof DirectoryError)) {
const e = err as { type?: string; message?: string };
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
return e?.message ?? String(err);
}
switch (err.type) {
case "forbidden":
return err.description ? t("The mail server refused this: {reason}", { reason: err.description }) : t("The mail server refused this. Your role may not allow it.");
case "primaryKeyViolation":
return t("That address is already in use on this server, as an account, a list or an alias.");
case "invalidForeignKey":
return t("One of the chosen domain, role or group can't be used for this account.");
case "overQuota":
return t("Your organisation has reached the number of accounts it is allowed.");
case "objectIsLinked":
return t("Something still depends on this, so the server kept it.");
case "notFound":
return t("This account no longer exists. Someone may have deleted it.");
case "invalidProperties":
if (err.properties.includes("secret")) return err.description ? t("The password was not accepted: {reason}", { reason: err.description }) : t("The password was not accepted.");
return err.description ? t("The mail server rejected a value: {reason}", { reason: err.description }) : t("The mail server rejected a value.");
default:
return err.description ?? err.type;
}
}
+71
View File
@@ -55,6 +55,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
"Administration": "Verwaltung",
"Directory": "Verzeichnis",
"User": "Benutzer",
"Administrator": "Administrator",
"Custom role": "Eigene Rolle",
"New account": "Neues Konto",
"The people who sign in to mail on the domains you manage.": "Die Personen, die sich auf den von Ihnen verwalteten Domains bei ihrer E-Mail anmelden.",
"Search by name or address": "Nach Name oder Adresse suchen",
"Search accounts": "Konten durchsuchen",
"No accounts match": "Keine passenden Konten",
"No accounts yet": "Noch keine Konten",
"Nothing on your domains matches “{query}”.": "Auf Ihren Domains passt nichts zu „{query}“.",
"Open {address}": "{address} öffnen",
"{from}{to} of {total}": "{from}{to} von {total}",
"Previous page": "Vorherige Seite",
"Next page": "Nächste Seite",
"Storage": "Speicher",
"Groups": "Gruppen",
"{used} · no limit": "{used} · ohne Begrenzung",
"Profile": "Profil",
"Domain": "Domain",
"No domains are available to create an account on.": "Es gibt keine Domain, auf der ein Konto angelegt werden kann.",
"Sign-in": "Anmeldung",
"Other addresses": "Weitere Adressen",
"Not in any group": "In keiner Gruppe",
"You can't change your own role.": "Sie können Ihre eigene Rolle nicht ändern.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Angeboten werden nur Rollen, deren Berechtigungen Sie selbst besitzen. Bei einem Konto innerhalb eines Mandanten bedeutet Administrator: Administrator dieses Mandanten.",
"Limit in GB": "Begrenzung in GB",
"No limit": "Ohne Begrenzung",
"This account has permissions yours doesn't, so you can view it but not change it.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat. Sie können es ansehen, aber nicht ändern.",
"Your role lets you view accounts but not change them.": "Ihre Rolle erlaubt es, Konten anzusehen, aber nicht zu ändern.",
"This account has permissions yours doesn't.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat.",
"You can't delete the account you're signed in with.": "Das Konto, mit dem Sie angemeldet sind, können Sie nicht löschen.",
"Create account": "Konto anlegen",
"An account needs an address.": "Ein Konto braucht eine Adresse.",
"Created {address}": "{address} angelegt",
"Saved {address}": "{address} gespeichert",
"Generate a password": "Passwort erzeugen",
"Pass it on some way other than email to this address.": "Geben Sie es nicht per E-Mail an diese Adresse weiter.",
"This account has no password. It may sign in through a directory or single sign-on.": "Dieses Konto hat kein Passwort. Möglicherweise meldet es sich über ein Verzeichnis oder Single Sign-on an.",
"Set a new password…": "Neues Passwort festlegen…",
"{name} will be signed out of every app and device using the old password.": "{name} wird in allen Apps und auf allen Geräten abgemeldet, die das alte Passwort verwenden.",
"New password set for {address}": "Neues Passwort für {address} festgelegt",
"Set password": "Passwort festlegen",
"Remove {address}": "{address} entfernen",
"New address": "Neue Adresse",
"another name": "anderer Name",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-Mails an diese Adressen werden diesem Konto zugestellt. Änderungen gelten nach dem Speichern.",
"Deletes the mailbox and everything in it.": "Löscht das Postfach und alles darin.",
"Delete account…": "Konto löschen…",
"Delete {address}?": "{address} löschen?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Dadurch werden die E-Mails, Kalender, Kontakte und Dateien dieses Kontos gelöscht. Der Server entfernt sie im Hintergrund, und es kann nicht rückgängig gemacht werden.",
"Type {address} to confirm": "Zur Bestätigung {address} eingeben",
"Delete account": "Konto löschen",
"Deleted {address}": "{address} gelöscht",
"The server did not say whether the account was created.": "Der Server hat nicht mitgeteilt, ob das Konto angelegt wurde.",
"The mail server refused this. Your role may not allow it.": "Der Mailserver hat dies abgelehnt. Ihre Rolle erlaubt es möglicherweise nicht.",
"The mail server refused this: {reason}": "Der Mailserver hat dies abgelehnt: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Diese Adresse wird auf diesem Server bereits verwendet als Konto, Liste oder Alias.",
"One of the chosen domain, role or group can't be used for this account.": "Die gewählte Domain, Rolle oder Gruppe kann für dieses Konto nicht verwendet werden.",
"Your organisation has reached the number of accounts it is allowed.": "Ihre Organisation hat die Anzahl der erlaubten Konten erreicht.",
"Something still depends on this, so the server kept it.": "Etwas hängt noch davon ab, daher hat der Server es behalten.",
"This account no longer exists. Someone may have deleted it.": "Dieses Konto existiert nicht mehr. Möglicherweise hat es jemand gelöscht.",
"The password was not accepted: {reason}": "Das Passwort wurde nicht akzeptiert: {reason}",
"The password was not accepted.": "Das Passwort wurde nicht akzeptiert.",
"The mail server rejected a value: {reason}": "Der Mailserver hat einen Wert abgelehnt: {reason}",
"The mail server rejected a value.": "Der Mailserver hat einen Wert abgelehnt.",
"Go to folder…": "Zu Ordner springen…", "Go to folder…": "Zu Ordner springen…",
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.", "Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
"Export iCAL file": "iCAL-Datei exportieren", "Export iCAL file": "iCAL-Datei exportieren",
@@ -1375,6 +1444,8 @@ export const catalog: Catalog = {
"no address": "keine Adresse", "no address": "keine Adresse",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} Konto", other: "{n} Konten" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" }, "Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" },
"Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" }, "Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" },
+71
View File
@@ -47,6 +47,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
"Administration": "Administración",
"Directory": "Directorio",
"User": "Usuario",
"Administrator": "Administrador",
"Custom role": "Rol personalizado",
"New account": "Nueva cuenta",
"The people who sign in to mail on the domains you manage.": "Las personas que inician sesión en el correo en los dominios que usted administra.",
"Search by name or address": "Buscar por nombre o dirección",
"Search accounts": "Buscar cuentas",
"No accounts match": "Ninguna cuenta coincide",
"No accounts yet": "Todavía no hay cuentas",
"Nothing on your domains matches “{query}”.": "Nada en sus dominios coincide con «{query}».",
"Open {address}": "Abrir {address}",
"{from}{to} of {total}": "{from}{to} de {total}",
"Previous page": "Página anterior",
"Next page": "Página siguiente",
"Storage": "Almacenamiento",
"Groups": "Grupos",
"{used} · no limit": "{used} · sin límite",
"Profile": "Perfil",
"Domain": "Dominio",
"No domains are available to create an account on.": "No hay ningún dominio disponible en el que crear una cuenta.",
"Sign-in": "Inicio de sesión",
"Other addresses": "Otras direcciones",
"Not in any group": "No pertenece a ningún grupo",
"You can't change your own role.": "No puede cambiar su propio rol.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Solo se ofrecen los roles cuyos permisos usted tiene. En una cuenta dentro de un inquilino, Administrador significa administrador de ese inquilino.",
"Limit in GB": "Límite en GB",
"No limit": "Sin límite",
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta cuenta tiene permisos que la suya no tiene, así que puede verla pero no modificarla.",
"Your role lets you view accounts but not change them.": "Su rol le permite ver las cuentas, pero no modificarlas.",
"This account has permissions yours doesn't.": "Esta cuenta tiene permisos que la suya no tiene.",
"You can't delete the account you're signed in with.": "No puede eliminar la cuenta con la que ha iniciado sesión.",
"Create account": "Crear cuenta",
"An account needs an address.": "Una cuenta necesita una dirección.",
"Created {address}": "{address} creada",
"Saved {address}": "{address} guardada",
"Generate a password": "Generar una contraseña",
"Pass it on some way other than email to this address.": "Comuníquela por otro medio que no sea un correo a esta dirección.",
"This account has no password. It may sign in through a directory or single sign-on.": "Esta cuenta no tiene contraseña. Puede que inicie sesión mediante un directorio o un inicio de sesión único.",
"Set a new password…": "Establecer una contraseña nueva…",
"{name} will be signed out of every app and device using the old password.": "Se cerrará la sesión de {name} en todas las aplicaciones y dispositivos que usen la contraseña anterior.",
"New password set for {address}": "Nueva contraseña establecida para {address}",
"Set password": "Establecer contraseña",
"Remove {address}": "Quitar {address}",
"New address": "Nueva dirección",
"another name": "otro nombre",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "El correo enviado a estas direcciones se entrega a esta cuenta. Los cambios se aplican al guardar.",
"Deletes the mailbox and everything in it.": "Elimina el buzón y todo su contenido.",
"Delete account…": "Eliminar cuenta…",
"Delete {address}?": "¿Eliminar {address}?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Esto elimina el correo, los calendarios, los contactos y los archivos de esta cuenta. El servidor los borra en segundo plano y no se puede deshacer.",
"Type {address} to confirm": "Escriba {address} para confirmar",
"Delete account": "Eliminar cuenta",
"Deleted {address}": "{address} eliminada",
"The server did not say whether the account was created.": "El servidor no indicó si la cuenta se creó.",
"The mail server refused this. Your role may not allow it.": "El servidor de correo lo ha rechazado. Es posible que su rol no lo permita.",
"The mail server refused this: {reason}": "El servidor de correo lo ha rechazado: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Esa dirección ya está en uso en este servidor, como cuenta, lista o alias.",
"One of the chosen domain, role or group can't be used for this account.": "El dominio, el rol o el grupo elegido no se puede usar para esta cuenta.",
"Your organisation has reached the number of accounts it is allowed.": "Su organización ha alcanzado el número de cuentas permitido.",
"Something still depends on this, so the server kept it.": "Algo todavía depende de esto, así que el servidor lo ha conservado.",
"This account no longer exists. Someone may have deleted it.": "Esta cuenta ya no existe. Puede que alguien la haya eliminado.",
"The password was not accepted: {reason}": "La contraseña no se ha aceptado: {reason}",
"The password was not accepted.": "La contraseña no se ha aceptado.",
"The mail server rejected a value: {reason}": "El servidor de correo ha rechazado un valor: {reason}",
"The mail server rejected a value.": "El servidor de correo ha rechazado un valor.",
"Go to folder…": "Ir a la carpeta…", "Go to folder…": "Ir a la carpeta…",
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.", "Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
"Export iCAL file": "Exportar archivo iCAL", "Export iCAL file": "Exportar archivo iCAL",
@@ -1348,6 +1417,8 @@ export const catalog: Catalog = {
"no address": "ninguna dirección", "no address": "ninguna dirección",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} cuenta", other: "{n} cuentas" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" }, "Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" },
"Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" }, "Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" },
+71
View File
@@ -52,6 +52,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
"Administration": "Administration",
"Directory": "Annuaire",
"User": "Utilisateur",
"Administrator": "Administrateur",
"Custom role": "Rôle personnalisé",
"New account": "Nouveau compte",
"The people who sign in to mail on the domains you manage.": "Les personnes qui se connectent à leur messagerie sur les domaines que vous gérez.",
"Search by name or address": "Rechercher par nom ou adresse",
"Search accounts": "Rechercher des comptes",
"No accounts match": "Aucun compte correspondant",
"No accounts yet": "Aucun compte pour linstant",
"Nothing on your domains matches “{query}”.": "Rien ne correspond à « {query} » sur vos domaines.",
"Open {address}": "Ouvrir {address}",
"{from}{to} of {total}": "{from}{to} sur {total}",
"Previous page": "Page précédente",
"Next page": "Page suivante",
"Storage": "Stockage",
"Groups": "Groupes",
"{used} · no limit": "{used} · sans limite",
"Profile": "Profil",
"Domain": "Domaine",
"No domains are available to create an account on.": "Aucun domaine nest disponible pour créer un compte.",
"Sign-in": "Connexion",
"Other addresses": "Autres adresses",
"Not in any group": "Membre daucun groupe",
"You can't change your own role.": "Vous ne pouvez pas modifier votre propre rôle.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Seuls les rôles dont vous détenez vous-même les autorisations sont proposés. Pour un compte au sein dun locataire, Administrateur signifie administrateur de ce locataire.",
"Limit in GB": "Limite en Go",
"No limit": "Sans limite",
"This account has permissions yours doesn't, so you can view it but not change it.": "Ce compte a des autorisations que le vôtre na pas : vous pouvez le consulter, mais pas le modifier.",
"Your role lets you view accounts but not change them.": "Votre rôle vous permet de consulter les comptes, mais pas de les modifier.",
"This account has permissions yours doesn't.": "Ce compte a des autorisations que le vôtre na pas.",
"You can't delete the account you're signed in with.": "Vous ne pouvez pas supprimer le compte avec lequel vous êtes connecté.",
"Create account": "Créer le compte",
"An account needs an address.": "Un compte doit avoir une adresse.",
"Created {address}": "{address} créé",
"Saved {address}": "{address} enregistré",
"Generate a password": "Générer un mot de passe",
"Pass it on some way other than email to this address.": "Transmettez-le autrement que par e-mail à cette adresse.",
"This account has no password. It may sign in through a directory or single sign-on.": "Ce compte na pas de mot de passe. Il se connecte peut-être via un annuaire ou une authentification unique.",
"Set a new password…": "Définir un nouveau mot de passe…",
"{name} will be signed out of every app and device using the old password.": "{name} sera déconnecté de toutes les applications et de tous les appareils qui utilisent lancien mot de passe.",
"New password set for {address}": "Nouveau mot de passe défini pour {address}",
"Set password": "Définir le mot de passe",
"Remove {address}": "Retirer {address}",
"New address": "Nouvelle adresse",
"another name": "autre nom",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Les messages envoyés à ces adresses sont remis à ce compte. Les modifications sappliquent à lenregistrement.",
"Deletes the mailbox and everything in it.": "Supprime la boîte aux lettres et tout son contenu.",
"Delete account…": "Supprimer le compte…",
"Delete {address}?": "Supprimer {address} ?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Cette action supprime les messages, agendas, contacts et fichiers de ce compte. Le serveur les efface en arrière-plan, et cest irréversible.",
"Type {address} to confirm": "Saisissez {address} pour confirmer",
"Delete account": "Supprimer le compte",
"Deleted {address}": "{address} supprimé",
"The server did not say whether the account was created.": "Le serveur na pas indiqué si le compte a été créé.",
"The mail server refused this. Your role may not allow it.": "Le serveur de messagerie a refusé. Votre rôle ne le permet peut-être pas.",
"The mail server refused this: {reason}": "Le serveur de messagerie a refusé : {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Cette adresse est déjà utilisée sur ce serveur, par un compte, une liste ou un alias.",
"One of the chosen domain, role or group can't be used for this account.": "Le domaine, le rôle ou le groupe choisi ne peut pas être utilisé pour ce compte.",
"Your organisation has reached the number of accounts it is allowed.": "Votre organisation a atteint le nombre de comptes autorisé.",
"Something still depends on this, so the server kept it.": "Un autre élément en dépend encore, le serveur la donc conservé.",
"This account no longer exists. Someone may have deleted it.": "Ce compte nexiste plus. Quelquun la peut-être supprimé.",
"The password was not accepted: {reason}": "Le mot de passe a été refusé : {reason}",
"The password was not accepted.": "Le mot de passe a été refusé.",
"The mail server rejected a value: {reason}": "Le serveur de messagerie a refusé une valeur : {reason}",
"The mail server rejected a value.": "Le serveur de messagerie a refusé une valeur.",
"Go to folder…": "Aller au dossier…", "Go to folder…": "Aller au dossier…",
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.", "Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
"Export iCAL file": "Exporter un fichier iCAL", "Export iCAL file": "Exporter un fichier iCAL",
@@ -1353,6 +1422,8 @@ export const catalog: Catalog = {
"no address": "aucune adresse", "no address": "aucune adresse",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} compte", other: "{n} comptes" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" }, "Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" },
"Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" }, "Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" },
+71
View File
@@ -46,6 +46,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
"Administration": "管理",
"Directory": "ディレクトリ",
"User": "ユーザー",
"Administrator": "管理者",
"Custom role": "カスタムロール",
"New account": "新しいアカウント",
"The people who sign in to mail on the domains you manage.": "管理しているドメインでメールにサインインするユーザーです。",
"Search by name or address": "名前またはアドレスで検索",
"Search accounts": "アカウントを検索",
"No accounts match": "一致するアカウントはありません",
"No accounts yet": "アカウントはまだありません",
"Nothing on your domains matches “{query}”.": "ドメイン内に「{query}」と一致するものはありません。",
"Open {address}": "{address} を開く",
"{from}{to} of {total}": "{from}{to} / {total}",
"Previous page": "前のページ",
"Next page": "次のページ",
"Storage": "ストレージ",
"Groups": "グループ",
"{used} · no limit": "{used} · 上限なし",
"Profile": "プロフィール",
"Domain": "ドメイン",
"No domains are available to create an account on.": "アカウントを作成できるドメインがありません。",
"Sign-in": "サインイン",
"Other addresses": "その他のアドレス",
"Not in any group": "どのグループにも属していません",
"You can't change your own role.": "自分のロールは変更できません。",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "ご自身が持つ権限だけで構成されたロールのみ表示されます。テナント内のアカウントでは、管理者はそのテナントの管理者を意味します。",
"Limit in GB": "上限(GB",
"No limit": "上限なし",
"This account has permissions yours doesn't, so you can view it but not change it.": "このアカウントにはあなたのアカウントにない権限があるため、閲覧はできますが変更はできません。",
"Your role lets you view accounts but not change them.": "あなたのロールでは、アカウントの閲覧はできますが変更はできません。",
"This account has permissions yours doesn't.": "このアカウントにはあなたのアカウントにない権限があります。",
"You can't delete the account you're signed in with.": "サインイン中のアカウントは削除できません。",
"Create account": "アカウントを作成",
"An account needs an address.": "アカウントにはアドレスが必要です。",
"Created {address}": "{address} を作成しました",
"Saved {address}": "{address} を保存しました",
"Generate a password": "パスワードを生成",
"Pass it on some way other than email to this address.": "このアドレス宛てのメール以外の方法で伝えてください。",
"This account has no password. It may sign in through a directory or single sign-on.": "このアカウントにはパスワードがありません。ディレクトリやシングルサインオンでサインインしている可能性があります。",
"Set a new password…": "新しいパスワードを設定…",
"{name} will be signed out of every app and device using the old password.": "{name} は、古いパスワードを使っているすべてのアプリとデバイスからサインアウトされます。",
"New password set for {address}": "{address} の新しいパスワードを設定しました",
"Set password": "パスワードを設定",
"Remove {address}": "{address} を削除",
"New address": "新しいアドレス",
"another name": "別の名前",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "これらのアドレス宛てのメールはこのアカウントに配信されます。変更は保存時に反映されます。",
"Deletes the mailbox and everything in it.": "メールボックスとその中身をすべて削除します。",
"Delete account…": "アカウントを削除…",
"Delete {address}?": "{address} を削除しますか?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "このアカウントのメール、カレンダー、連絡先、ファイルが削除されます。サーバーがバックグラウンドで削除し、元に戻すことはできません。",
"Type {address} to confirm": "確認のため {address} と入力してください",
"Delete account": "アカウントを削除",
"Deleted {address}": "{address} を削除しました",
"The server did not say whether the account was created.": "アカウントが作成されたかどうか、サーバーから応答がありませんでした。",
"The mail server refused this. Your role may not allow it.": "メールサーバーに拒否されました。ロールで許可されていない可能性があります。",
"The mail server refused this: {reason}": "メールサーバーに拒否されました: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "このアドレスは、アカウント、リスト、またはエイリアスとして、このサーバーですでに使われています。",
"One of the chosen domain, role or group can't be used for this account.": "選択したドメイン、ロール、またはグループはこのアカウントには使えません。",
"Your organisation has reached the number of accounts it is allowed.": "組織で許可されているアカウント数の上限に達しました。",
"Something still depends on this, so the server kept it.": "まだこれに依存しているものがあるため、サーバーは削除しませんでした。",
"This account no longer exists. Someone may have deleted it.": "このアカウントはもう存在しません。誰かが削除した可能性があります。",
"The password was not accepted: {reason}": "パスワードは受け付けられませんでした: {reason}",
"The password was not accepted.": "パスワードは受け付けられませんでした。",
"The mail server rejected a value: {reason}": "メールサーバーが値を拒否しました: {reason}",
"The mail server rejected a value.": "メールサーバーが値を拒否しました。",
"Go to folder…": "フォルダーへ移動…", "Go to folder…": "フォルダーへ移動…",
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。", "Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
"Export iCAL file": "iCAL ファイルをエクスポート", "Export iCAL file": "iCAL ファイルをエクスポート",
@@ -1356,6 +1425,8 @@ export const catalog: Catalog = {
"no address": "アドレスなし", "no address": "アドレスなし",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { other: "{n} 件のアカウント" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "{n} 件を削除" }, "Delete {n} items": { other: "{n} 件を削除" },
"Delete {n} items?": { other: "{n} 件を削除しますか?" }, "Delete {n} items?": { other: "{n} 件を削除しますか?" },
+71
View File
@@ -43,6 +43,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
"Administration": "Beheer",
"Directory": "Adreslijst",
"User": "Gebruiker",
"Administrator": "Beheerder",
"Custom role": "Aangepaste rol",
"New account": "Nieuw account",
"The people who sign in to mail on the domains you manage.": "De mensen die op de domeinen die u beheert inloggen op hun e-mail.",
"Search by name or address": "Zoeken op naam of adres",
"Search accounts": "Accounts zoeken",
"No accounts match": "Geen accounts gevonden",
"No accounts yet": "Nog geen accounts",
"Nothing on your domains matches “{query}”.": "Niets op uw domeinen komt overeen met {query}.",
"Open {address}": "{address} openen",
"{from}{to} of {total}": "{from}{to} van {total}",
"Previous page": "Vorige pagina",
"Next page": "Volgende pagina",
"Storage": "Opslag",
"Groups": "Groepen",
"{used} · no limit": "{used} · geen limiet",
"Profile": "Profiel",
"Domain": "Domein",
"No domains are available to create an account on.": "Er is geen domein beschikbaar om een account op aan te maken.",
"Sign-in": "Inloggen",
"Other addresses": "Andere adressen",
"Not in any group": "Geen lid van een groep",
"You can't change your own role.": "U kunt uw eigen rol niet wijzigen.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Alleen rollen waarvan u de rechten zelf hebt, worden aangeboden. Bij een account binnen een tenant betekent Beheerder: beheerder van die tenant.",
"Limit in GB": "Limiet in GB",
"No limit": "Geen limiet",
"This account has permissions yours doesn't, so you can view it but not change it.": "Dit account heeft rechten die het uwe niet heeft. U kunt het bekijken, maar niet wijzigen.",
"Your role lets you view accounts but not change them.": "Met uw rol kunt u accounts bekijken, maar niet wijzigen.",
"This account has permissions yours doesn't.": "Dit account heeft rechten die het uwe niet heeft.",
"You can't delete the account you're signed in with.": "U kunt het account waarmee u bent ingelogd niet verwijderen.",
"Create account": "Account aanmaken",
"An account needs an address.": "Een account heeft een adres nodig.",
"Created {address}": "{address} aangemaakt",
"Saved {address}": "{address} opgeslagen",
"Generate a password": "Wachtwoord genereren",
"Pass it on some way other than email to this address.": "Geef het door op een andere manier dan per e-mail naar dit adres.",
"This account has no password. It may sign in through a directory or single sign-on.": "Dit account heeft geen wachtwoord. Mogelijk logt het in via een adreslijst of single sign-on.",
"Set a new password…": "Nieuw wachtwoord instellen…",
"{name} will be signed out of every app and device using the old password.": "{name} wordt uitgelogd in alle apps en op alle apparaten die het oude wachtwoord gebruiken.",
"New password set for {address}": "Nieuw wachtwoord ingesteld voor {address}",
"Set password": "Wachtwoord instellen",
"Remove {address}": "{address} verwijderen",
"New address": "Nieuw adres",
"another name": "andere naam",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-mail aan deze adressen wordt in dit account afgeleverd. Wijzigingen gelden na opslaan.",
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles erin.",
"Delete account…": "Account verwijderen…",
"Delete {address}?": "{address} verwijderen?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mail, agendas, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
"Type {address} to confirm": "Typ {address} om te bevestigen",
"Delete account": "Account verwijderen",
"Deleted {address}": "{address} verwijderd",
"The server did not say whether the account was created.": "De server heeft niet gemeld of het account is aangemaakt.",
"The mail server refused this. Your role may not allow it.": "De mailserver heeft dit geweigerd. Uw rol staat het mogelijk niet toe.",
"The mail server refused this: {reason}": "De mailserver heeft dit geweigerd: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Dat adres is op deze server al in gebruik, als account, lijst of alias.",
"One of the chosen domain, role or group can't be used for this account.": "Het gekozen domein, de rol of de groep kan niet voor dit account worden gebruikt.",
"Your organisation has reached the number of accounts it is allowed.": "Uw organisatie heeft het toegestane aantal accounts bereikt.",
"Something still depends on this, so the server kept it.": "Er hangt nog iets van af, dus de server heeft het behouden.",
"This account no longer exists. Someone may have deleted it.": "Dit account bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
"The password was not accepted: {reason}": "Het wachtwoord is niet geaccepteerd: {reason}",
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
"The mail server rejected a value: {reason}": "De mailserver heeft een waarde geweigerd: {reason}",
"The mail server rejected a value.": "De mailserver heeft een waarde geweigerd.",
"Go to folder…": "Ga naar map…", "Go to folder…": "Ga naar map…",
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.", "Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
"Export iCAL file": "iCAL-bestand exporteren", "Export iCAL file": "iCAL-bestand exporteren",
@@ -1344,6 +1413,8 @@ export const catalog: Catalog = {
"no address": "geen adres", "no address": "geen adres",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} account", other: "{n} accounts" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" }, "Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" },
"Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" }, "Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" },
+71
View File
@@ -50,6 +50,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
"Administration": "Administração",
"Directory": "Diretório",
"User": "Usuário",
"Administrator": "Administrador",
"Custom role": "Função personalizada",
"New account": "Nova conta",
"The people who sign in to mail on the domains you manage.": "As pessoas que entram no e-mail nos domínios que você administra.",
"Search by name or address": "Pesquisar por nome ou endereço",
"Search accounts": "Pesquisar contas",
"No accounts match": "Nenhuma conta corresponde",
"No accounts yet": "Ainda não há contas",
"Nothing on your domains matches “{query}”.": "Nada nos seus domínios corresponde a “{query}”.",
"Open {address}": "Abrir {address}",
"{from}{to} of {total}": "{from}{to} de {total}",
"Previous page": "Página anterior",
"Next page": "Próxima página",
"Storage": "Armazenamento",
"Groups": "Grupos",
"{used} · no limit": "{used} · sem limite",
"Profile": "Perfil",
"Domain": "Domínio",
"No domains are available to create an account on.": "Não há nenhum domínio disponível para criar uma conta.",
"Sign-in": "Acesso",
"Other addresses": "Outros endereços",
"Not in any group": "Não está em nenhum grupo",
"You can't change your own role.": "Você não pode alterar sua própria função.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Só são oferecidas as funções cujas permissões você mesmo tem. Em uma conta dentro de um locatário, Administrador significa administrador desse locatário.",
"Limit in GB": "Limite em GB",
"No limit": "Sem limite",
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta conta tem permissões que a sua não tem, então você pode vê-la, mas não alterá-la.",
"Your role lets you view accounts but not change them.": "Sua função permite ver as contas, mas não alterá-las.",
"This account has permissions yours doesn't.": "Esta conta tem permissões que a sua não tem.",
"You can't delete the account you're signed in with.": "Você não pode excluir a conta com a qual está conectado.",
"Create account": "Criar conta",
"An account needs an address.": "Uma conta precisa de um endereço.",
"Created {address}": "{address} criada",
"Saved {address}": "{address} salva",
"Generate a password": "Gerar uma senha",
"Pass it on some way other than email to this address.": "Repasse-a por outro meio que não seja um e-mail para este endereço.",
"This account has no password. It may sign in through a directory or single sign-on.": "Esta conta não tem senha. Ela pode entrar por meio de um diretório ou de login único.",
"Set a new password…": "Definir nova senha…",
"{name} will be signed out of every app and device using the old password.": "{name} será desconectado de todos os apps e dispositivos que usam a senha antiga.",
"New password set for {address}": "Nova senha definida para {address}",
"Set password": "Definir senha",
"Remove {address}": "Remover {address}",
"New address": "Novo endereço",
"another name": "outro nome",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Os e-mails enviados a estes endereços são entregues nesta conta. As alterações valem ao salvar.",
"Deletes the mailbox and everything in it.": "Exclui a caixa de correio e tudo o que há nela.",
"Delete account…": "Excluir conta…",
"Delete {address}?": "Excluir {address}?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Isto exclui os e-mails, agendas, contatos e arquivos desta conta. O servidor os remove em segundo plano, e não é possível desfazer.",
"Type {address} to confirm": "Digite {address} para confirmar",
"Delete account": "Excluir conta",
"Deleted {address}": "{address} excluída",
"The server did not say whether the account was created.": "O servidor não informou se a conta foi criada.",
"The mail server refused this. Your role may not allow it.": "O servidor de e-mail recusou. Talvez sua função não permita.",
"The mail server refused this: {reason}": "O servidor de e-mail recusou: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Esse endereço já está em uso neste servidor, como conta, lista ou alias.",
"One of the chosen domain, role or group can't be used for this account.": "O domínio, a função ou o grupo escolhido não pode ser usado nesta conta.",
"Your organisation has reached the number of accounts it is allowed.": "Sua organização atingiu o número de contas permitido.",
"Something still depends on this, so the server kept it.": "Algo ainda depende disto, então o servidor o manteve.",
"This account no longer exists. Someone may have deleted it.": "Esta conta não existe mais. Talvez alguém a tenha excluído.",
"The password was not accepted: {reason}": "A senha não foi aceita: {reason}",
"The password was not accepted.": "A senha não foi aceita.",
"The mail server rejected a value: {reason}": "O servidor de e-mail recusou um valor: {reason}",
"The mail server rejected a value.": "O servidor de e-mail recusou um valor.",
"Go to folder…": "Ir para a pasta…", "Go to folder…": "Ir para a pasta…",
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.", "Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
"Export iCAL file": "Exportar arquivo iCAL", "Export iCAL file": "Exportar arquivo iCAL",
@@ -1351,6 +1420,8 @@ export const catalog: Catalog = {
"no address": "nenhum endereço", "no address": "nenhum endereço",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} conta", other: "{n} contas" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" }, "Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" },
"Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" }, "Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" },
+71
View File
@@ -49,6 +49,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
"Administration": "Администрирование",
"Directory": "Каталог",
"User": "Пользователь",
"Administrator": "Администратор",
"Custom role": "Особая роль",
"New account": "Новая учётная запись",
"The people who sign in to mail on the domains you manage.": "Люди, которые входят в почту на доменах, которыми вы управляете.",
"Search by name or address": "Поиск по имени или адресу",
"Search accounts": "Поиск учётных записей",
"No accounts match": "Нет подходящих учётных записей",
"No accounts yet": "Учётных записей пока нет",
"Nothing on your domains matches “{query}”.": "На ваших доменах нет совпадений с «{query}».",
"Open {address}": "Открыть {address}",
"{from}{to} of {total}": "{from}{to} из {total}",
"Previous page": "Предыдущая страница",
"Next page": "Следующая страница",
"Storage": "Хранилище",
"Groups": "Группы",
"{used} · no limit": "{used} · без ограничения",
"Profile": "Профиль",
"Domain": "Домен",
"No domains are available to create an account on.": "Нет доменов, на которых можно создать учётную запись.",
"Sign-in": "Вход",
"Other addresses": "Другие адреса",
"Not in any group": "Не состоит ни в одной группе",
"You can't change your own role.": "Нельзя изменить собственную роль.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Предлагаются только роли, все разрешения которых есть у вас самих. Для учётной записи внутри арендатора «Администратор» означает администратора этого арендатора.",
"Limit in GB": "Ограничение в ГБ",
"No limit": "Без ограничения",
"This account has permissions yours doesn't, so you can view it but not change it.": "У этой учётной записи есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
"Your role lets you view accounts but not change them.": "Ваша роль позволяет просматривать учётные записи, но не изменять их.",
"This account has permissions yours doesn't.": "У этой учётной записи есть разрешения, которых нет у вашей.",
"You can't delete the account you're signed in with.": "Нельзя удалить учётную запись, под которой вы вошли.",
"Create account": "Создать учётную запись",
"An account needs an address.": "Учётной записи нужен адрес.",
"Created {address}": "Учётная запись {address} создана",
"Saved {address}": "Учётная запись {address} сохранена",
"Generate a password": "Сгенерировать пароль",
"Pass it on some way other than email to this address.": "Передайте его любым способом, кроме письма на этот адрес.",
"This account has no password. It may sign in through a directory or single sign-on.": "У этой учётной записи нет пароля. Возможно, вход выполняется через каталог или единый вход.",
"Set a new password…": "Задать новый пароль…",
"{name} will be signed out of every app and device using the old password.": "Для {name} будет выполнен выход во всех приложениях и на всех устройствах, где используется старый пароль.",
"New password set for {address}": "Новый пароль для {address} задан",
"Set password": "Задать пароль",
"Remove {address}": "Удалить {address}",
"New address": "Новый адрес",
"another name": "другое имя",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Письма на эти адреса доставляются в эту учётную запись. Изменения вступят в силу после сохранения.",
"Deletes the mailbox and everything in it.": "Удаляет почтовый ящик и всё его содержимое.",
"Delete account…": "Удалить учётную запись…",
"Delete {address}?": "Удалить {address}?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Будут удалены почта, календари, контакты и файлы этой учётной записи. Сервер удалит их в фоновом режиме, отменить это нельзя.",
"Type {address} to confirm": "Введите {address} для подтверждения",
"Delete account": "Удалить учётную запись",
"Deleted {address}": "Учётная запись {address} удалена",
"The server did not say whether the account was created.": "Сервер не сообщил, создана ли учётная запись.",
"The mail server refused this. Your role may not allow it.": "Почтовый сервер отклонил это действие. Возможно, ваша роль его не допускает.",
"The mail server refused this: {reason}": "Почтовый сервер отклонил это действие: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Этот адрес уже используется на сервере — учётной записью, списком или псевдонимом.",
"One of the chosen domain, role or group can't be used for this account.": "Выбранный домен, роль или группу нельзя использовать для этой учётной записи.",
"Your organisation has reached the number of accounts it is allowed.": "Ваша организация достигла допустимого числа учётных записей.",
"Something still depends on this, so the server kept it.": "От этого ещё что-то зависит, поэтому сервер это сохранил.",
"This account no longer exists. Someone may have deleted it.": "Этой учётной записи больше нет. Возможно, её кто-то удалил.",
"The password was not accepted: {reason}": "Пароль не принят: {reason}",
"The password was not accepted.": "Пароль не принят.",
"The mail server rejected a value: {reason}": "Почтовый сервер отклонил значение: {reason}",
"The mail server rejected a value.": "Почтовый сервер отклонил значение.",
"Go to folder…": "Перейти к папке…", "Go to folder…": "Перейти к папке…",
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.", "Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
"Export iCAL file": "Экспортировать файл iCAL", "Export iCAL file": "Экспортировать файл iCAL",
@@ -1350,6 +1419,8 @@ export const catalog: Catalog = {
"no address": "нет адреса", "no address": "нет адреса",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} учётная запись", few: "{n} учётные записи", many: "{n} учётных записей", other: "{n} учётной записи" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" }, "Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" },
"Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" }, "Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" },
+71
View File
@@ -43,6 +43,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
"Administration": "Адміністрування",
"Directory": "Каталог",
"User": "Користувач",
"Administrator": "Адміністратор",
"Custom role": "Власна роль",
"New account": "Новий обліковий запис",
"The people who sign in to mail on the domains you manage.": "Люди, які входять у пошту на доменах, якими ви керуєте.",
"Search by name or address": "Пошук за іменем або адресою",
"Search accounts": "Пошук облікових записів",
"No accounts match": "Немає відповідних облікових записів",
"No accounts yet": "Облікових записів ще немає",
"Nothing on your domains matches “{query}”.": "На ваших доменах немає збігів із «{query}».",
"Open {address}": "Відкрити {address}",
"{from}{to} of {total}": "{from}{to} із {total}",
"Previous page": "Попередня сторінка",
"Next page": "Наступна сторінка",
"Storage": "Сховище",
"Groups": "Групи",
"{used} · no limit": "{used} · без обмеження",
"Profile": "Профіль",
"Domain": "Домен",
"No domains are available to create an account on.": "Немає доменів, на яких можна створити обліковий запис.",
"Sign-in": "Вхід",
"Other addresses": "Інші адреси",
"Not in any group": "Не входить до жодної групи",
"You can't change your own role.": "Ви не можете змінити власну роль.",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Пропонуються лише ролі, усі дозволи яких маєте ви самі. Для облікового запису всередині орендаря «Адміністратор» означає адміністратора цього орендаря.",
"Limit in GB": "Обмеження в ГБ",
"No limit": "Без обмеження",
"This account has permissions yours doesn't, so you can view it but not change it.": "Цей обліковий запис має дозволи, яких немає у вашого, тому його можна переглядати, але не змінювати.",
"Your role lets you view accounts but not change them.": "Ваша роль дозволяє переглядати облікові записи, але не змінювати їх.",
"This account has permissions yours doesn't.": "Цей обліковий запис має дозволи, яких немає у вашого.",
"You can't delete the account you're signed in with.": "Не можна видалити обліковий запис, під яким ви ввійшли.",
"Create account": "Створити обліковий запис",
"An account needs an address.": "Обліковому запису потрібна адреса.",
"Created {address}": "Обліковий запис {address} створено",
"Saved {address}": "Обліковий запис {address} збережено",
"Generate a password": "Згенерувати пароль",
"Pass it on some way other than email to this address.": "Передайте його будь-яким способом, окрім листа на цю адресу.",
"This account has no password. It may sign in through a directory or single sign-on.": "Цей обліковий запис не має пароля. Можливо, вхід виконується через каталог або єдиний вхід.",
"Set a new password…": "Задати новий пароль…",
"{name} will be signed out of every app and device using the old password.": "Для {name} буде виконано вихід у всіх застосунках і на всіх пристроях, де використовується старий пароль.",
"New password set for {address}": "Новий пароль для {address} задано",
"Set password": "Задати пароль",
"Remove {address}": "Видалити {address}",
"New address": "Нова адреса",
"another name": "інше ім'я",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Листи на ці адреси доставляються в цей обліковий запис. Зміни наберуть чинності після збереження.",
"Deletes the mailbox and everything in it.": "Видаляє поштову скриньку й усе, що в ній.",
"Delete account…": "Видалити обліковий запис…",
"Delete {address}?": "Видалити {address}?",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Буде видалено пошту, календарі, контакти й файли цього облікового запису. Сервер видалить їх у фоновому режимі, скасувати це неможливо.",
"Type {address} to confirm": "Введіть {address} для підтвердження",
"Delete account": "Видалити обліковий запис",
"Deleted {address}": "Обліковий запис {address} видалено",
"The server did not say whether the account was created.": "Сервер не повідомив, чи створено обліковий запис.",
"The mail server refused this. Your role may not allow it.": "Поштовий сервер відхилив цю дію. Можливо, ваша роль її не дозволяє.",
"The mail server refused this: {reason}": "Поштовий сервер відхилив цю дію: {reason}",
"That address is already in use on this server, as an account, a list or an alias.": "Ця адреса вже використовується на сервері — обліковим записом, списком або псевдонімом.",
"One of the chosen domain, role or group can't be used for this account.": "Вибраний домен, роль або групу не можна використати для цього облікового запису.",
"Your organisation has reached the number of accounts it is allowed.": "Ваша організація досягла дозволеної кількості облікових записів.",
"Something still depends on this, so the server kept it.": "Від цього ще щось залежить, тому сервер це зберіг.",
"This account no longer exists. Someone may have deleted it.": "Цього облікового запису більше немає. Можливо, його хтось видалив.",
"The password was not accepted: {reason}": "Пароль не прийнято: {reason}",
"The password was not accepted.": "Пароль не прийнято.",
"The mail server rejected a value: {reason}": "Поштовий сервер відхилив значення: {reason}",
"The mail server rejected a value.": "Поштовий сервер відхилив значення.",
"Go to folder…": "Перейти до теки…", "Go to folder…": "Перейти до теки…",
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.", "Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
"Export iCAL file": "Експортувати файл iCAL", "Export iCAL file": "Експортувати файл iCAL",
@@ -1344,6 +1413,8 @@ export const catalog: Catalog = {
"no address": "немає адреси", "no address": "немає адреси",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { one: "{n} обліковий запис", few: "{n} облікові записи", many: "{n} облікових записів", other: "{n} облікового запису" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" }, "Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" },
"Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" }, "Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" },
+71
View File
@@ -45,6 +45,75 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
// ── Administration: accounts ───────────────────────────────────
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
"Administration": "管理",
"Directory": "目录",
"User": "用户",
"Administrator": "管理员",
"Custom role": "自定义角色",
"New account": "新建账户",
"The people who sign in to mail on the domains you manage.": "在您管理的域名上登录邮箱的人员。",
"Search by name or address": "按姓名或地址搜索",
"Search accounts": "搜索账户",
"No accounts match": "没有匹配的账户",
"No accounts yet": "暂无账户",
"Nothing on your domains matches “{query}”.": "您的域名中没有与「{query}」匹配的内容。",
"Open {address}": "打开 {address}",
"{from}{to} of {total}": "第 {from}{to} 个,共 {total} 个",
"Previous page": "上一页",
"Next page": "下一页",
"Storage": "存储",
"Groups": "群组",
"{used} · no limit": "{used} · 无限制",
"Profile": "资料",
"Domain": "域名",
"No domains are available to create an account on.": "没有可用于创建账户的域名。",
"Sign-in": "登录",
"Other addresses": "其他地址",
"Not in any group": "不属于任何群组",
"You can't change your own role.": "您无法更改自己的角色。",
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "仅提供您本人拥有其全部权限的角色。对于租户内的账户,管理员指的是该租户的管理员。",
"Limit in GB": "限额(GB",
"No limit": "无限制",
"This account has permissions yours doesn't, so you can view it but not change it.": "该账户拥有您的账户所没有的权限,因此您只能查看,无法更改。",
"Your role lets you view accounts but not change them.": "您的角色可以查看账户,但不能更改。",
"This account has permissions yours doesn't.": "该账户拥有您的账户所没有的权限。",
"You can't delete the account you're signed in with.": "您无法删除当前登录的账户。",
"Create account": "创建账户",
"An account needs an address.": "账户需要一个地址。",
"Created {address}": "已创建 {address}",
"Saved {address}": "已保存 {address}",
"Generate a password": "生成密码",
"Pass it on some way other than email to this address.": "请通过发往此地址的邮件以外的方式转交。",
"This account has no password. It may sign in through a directory or single sign-on.": "该账户没有密码,可能通过目录服务或单点登录进行登录。",
"Set a new password…": "设置新密码…",
"{name} will be signed out of every app and device using the old password.": "{name} 将在所有使用旧密码的应用和设备上被退出登录。",
"New password set for {address}": "已为 {address} 设置新密码",
"Set password": "设置密码",
"Remove {address}": "移除 {address}",
"New address": "新地址",
"another name": "其他名称",
"Mail to these addresses is delivered to this account. Changes apply when you save.": "发往这些地址的邮件会投递到此账户。更改在保存后生效。",
"Deletes the mailbox and everything in it.": "删除邮箱及其中的全部内容。",
"Delete account…": "删除账户…",
"Delete {address}?": "删除 {address}",
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "这将删除此账户中的邮件、日历、联系人和文件。服务器会在后台移除它们,且无法撤销。",
"Type {address} to confirm": "输入 {address} 以确认",
"Delete account": "删除账户",
"Deleted {address}": "已删除 {address}",
"The server did not say whether the account was created.": "服务器没有说明账户是否已创建。",
"The mail server refused this. Your role may not allow it.": "邮件服务器拒绝了此操作。您的角色可能不允许。",
"The mail server refused this: {reason}": "邮件服务器拒绝了此操作:{reason}",
"That address is already in use on this server, as an account, a list or an alias.": "该地址已在此服务器上被账户、列表或别名使用。",
"One of the chosen domain, role or group can't be used for this account.": "所选的域名、角色或群组无法用于此账户。",
"Your organisation has reached the number of accounts it is allowed.": "您的组织已达到允许的账户数量上限。",
"Something still depends on this, so the server kept it.": "仍有其他内容依赖于它,因此服务器保留了它。",
"This account no longer exists. Someone may have deleted it.": "该账户已不存在,可能已被他人删除。",
"The password was not accepted: {reason}": "密码未被接受:{reason}",
"The password was not accepted.": "密码未被接受。",
"The mail server rejected a value: {reason}": "邮件服务器拒绝了一个值:{reason}",
"The mail server rejected a value.": "邮件服务器拒绝了一个值。",
"Go to folder…": "转到文件夹…", "Go to folder…": "转到文件夹…",
"Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。", "Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。",
"Export iCAL file": "导出 iCAL 文件", "Export iCAL file": "导出 iCAL 文件",
@@ -1355,6 +1424,8 @@ export const catalog: Catalog = {
"no address": "无地址", "no address": "无地址",
}, },
plurals: { plurals: {
// ── Administration ────────────────────────────────────────────────
"{n} accounts": { other: "{n} 个账户" },
// ── Third pass ───────────────────────────────────────────────────── // ── Third pass ─────────────────────────────────────────────────────
"Delete {n} items": { other: "删除 {n} 个项目" }, "Delete {n} items": { other: "删除 {n} 个项目" },
"Delete {n} items?": { other: "要删除 {n} 个项目吗?" }, "Delete {n} items?": { other: "要删除 {n} 个项目吗?" },
+58
View File
@@ -1673,6 +1673,62 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); } .sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; } .sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; }
/* ==========================================================================
Administration
Settings' layout, with a table for the list and a panel beside it for the
one that is open. The panel is positioned against the layout rather than the
scrolling content, so it stays put while the list scrolls under it.
========================================================================== */
.admin-layout { position: relative; }
.admin-content { max-width: 960px; }
.admin-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.admin-head .grow { min-width: 220px; }
.admin-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.admin-search { position: relative; flex: 1; max-width: 360px; }
.admin-search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--fg-faint); pointer-events: none; }
.admin-search .input { width: 100%; padding-left: 34px; }
.admin-table-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: .93em; }
.admin-table th { text-align: left; padding: 9px 12px; font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); background: var(--bg-sunken); border-bottom: 1px solid var(--border); white-space: nowrap; }
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table tbody tr { cursor: pointer; }
.admin-table tbody tr:hover { background: var(--bg-hover); }
.admin-table tbody tr.selected { background: var(--bg-active); }
.admin-table tbody tr:focus-visible { outline: none; box-shadow: inset var(--focus-ring); }
.admin-who { display: flex; align-items: center; gap: 10px; min-width: 200px; }
.admin-who-name { font-weight: 550; display: flex; align-items: center; gap: 6px; }
.admin-groups { display: block; max-width: 180px; }
.admin-role { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .85em; font-weight: 550; white-space: nowrap; background: var(--bg-sunken); color: var(--fg-muted); }
.admin-role.admin { background: var(--accent-soft); color: var(--accent-soft-fg); }
.admin-role.custom { background: transparent; border: 1px solid var(--border-strong); }
.admin-meter { min-width: 120px; }
.admin-meter .quota-bar { margin: 0 0 4px; }
.admin-pager { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; }
.admin-pager .hint { margin-right: 8px; font-variant-numeric: tabular-nums; }
.admin-count { margin-top: 8px; }
.admin-notice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--fg-muted); font-size: .92em; margin: 8px 0; }
.admin-notice svg { flex: none; margin-top: 2px; }
.admin-notice.warn { background: var(--warn-soft); color: var(--fg); }
.admin-notice.warn svg { color: var(--warn); }
.admin-notice.error { background: var(--danger-soft); color: var(--fg); }
.admin-sheet { position: absolute; top: 0; right: 0; bottom: 0; width: min(460px, 100%); z-index: 20; display: flex; flex-direction: column; background: var(--bg-elev); border-left: 1px solid var(--border); box-shadow: var(--shadow-3); animation: admin-sheet-in .18s var(--ease); }
@keyframes admin-sheet-in { from { transform: translateX(24px); opacity: 0; } }
.admin-sheet-head { display: flex; align-items: center; gap: 12px; padding: 14px 12px 12px 20px; border-bottom: 1px solid var(--border); }
.admin-sheet-head h2 { margin: 0; padding: 0; border: 0; font-size: 1.1em; font-weight: 650; }
.admin-sheet-body { flex: 1; overflow-y: auto; padding: 4px 20px 24px; }
.admin-sheet-body h3 { margin: 22px 0 10px; font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-faint); }
.admin-sheet-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
.admin-address .input:first-child { flex: 0 1 160px; min-width: 0; }
.admin-address select.input { flex: 1; min-width: 0; }
.admin-wide { width: 100%; }
.admin-narrow { max-width: 140px; }
.admin-danger { border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); border-radius: var(--radius); padding: 12px 14px; }
.admin-danger p { margin: 0 0 10px; color: var(--fg-muted); font-size: .92em; }
.admin-danger-btn { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.admin-danger-btn:hover:not(:disabled) { background: var(--danger-soft); }
@media (prefers-reduced-motion: reduce) { .admin-sheet { animation: none; } }
/* ========================================================================== /* ==========================================================================
Contacts Contacts
========================================================================== */ ========================================================================== */
@@ -1956,6 +2012,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.settings-layout.root .settings-nav { display: block; border-right: 0; } .settings-layout.root .settings-nav { display: block; border-right: 0; }
.settings-layout.root .settings-content { display: none; } .settings-layout.root .settings-content { display: none; }
.settings-content { --pad-b: 80px; padding: 16px 16px var(--pad-b); } .settings-content { --pad-b: 80px; padding: 16px 16px var(--pad-b); }
.admin-table .hide-mobile { display: none; }
.admin-sheet { width: 100%; border-left: 0; box-shadow: none; }
.contacts-layout { grid-template-columns: 1fr; } .contacts-layout { grid-template-columns: 1fr; }
.contacts-books { display: none; } .contacts-books { display: none; }
.contacts-layout.detail .contacts-list { display: none; } .contacts-layout.detail .contacts-list { display: none; }
+8 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { DEFAULT_APP_NAME } from "@/lib/brand"; import { DEFAULT_APP_NAME } from "@/lib/brand";
@@ -21,6 +21,8 @@ import { formatSize } from "@/lib/format";
import { collectShare } from "@/lib/shareTarget"; import { collectShare } from "@/lib/shareTarget";
import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions";
const PUSH_LABEL = { const PUSH_LABEL = {
connected: "Live updates connected", connected: "Live updates connected",
@@ -42,6 +44,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const logout = useSession((s) => s.logout); const logout = useSession((s) => s.logout);
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
const acctMenu = useMenu(); const acctMenu = useMenu();
const administers = hasAdministration(usePermissions());
/* /*
* "Go to folder" (#233), hosted here rather than in the mail view because * "Go to folder" (#233), hosted here rather than in the mail view because
* the `g` shortcuts are global: pressing it from the calendar should still * the `g` shortcuts are global: pressing it from the calendar should still
@@ -156,6 +159,9 @@ export function AppShell({ children }: { children: ReactNode }) {
app there was no way back to it. */} app there was no way back to it. */}
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external /> <MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} /> <MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
{/* Only for an account whose Stalwart role manages other accounts.
Nobody else is shown an entry that would open onto refusals. */}
{administers && <MenuItem icon={<ShieldCheck size={16} />} label={t("Administration")} active={section === "admin"} onClick={() => navigate("/admin")} />}
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} /> <MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} /> <MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
</Popover> </Popover>
@@ -207,6 +213,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{section === "contacts" && <ContactsSidebar />} {section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />} {section === "files" && <FilesTree />}
{section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>} {section === "settings" && <div className="nav-section"><span>{t("Settings")}</span></div>}
{section === "admin" && <div className="nav-section"><span>{t("Administration")}</span></div>}
</div> </div>
{(section === "mail" || section === "search") && <QuotaBar />} {(section === "mail" || section === "search") && <QuotaBar />}
<nav className="module-bar" aria-label={t("Go to")}> <nav className="module-bar" aria-label={t("Go to")}>
+459
View File
@@ -0,0 +1,459 @@
import { useEffect, useMemo, useState } from "react";
import { Copy, Dices, KeyRound, Lock, Plus, Trash2, X } from "lucide-react";
import {
ADMIN_BASELINE,
can,
canGrantRole,
generatePassword,
outranks,
type UserRoles,
} from "@/lib/adminAccess";
import {
aliasList,
createAccount,
describeDirectoryError,
destroyAccount,
hasPassword,
passwordPatch,
quotasWithDisk,
updateAccount,
DISK_QUOTA,
type DirectoryAccount,
type EmailAlias,
} from "@/lib/adminDirectory";
import { formatSize } from "@/lib/format";
import { t, tNode } from "@/lib/i18n";
import { Link } from "wouter";
import { Avatar } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions";
const GIB = 1024 ** 3;
interface Props {
/** Null to create one. */
account: DirectoryAccount | null;
ctx: DirectoryContext;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/** A role as one select value: "User", "Admin", or "custom:<ids>". */
function roleKey(roles: UserRoles | undefined): string {
if (!roles || roles["@type"] === "User") return "User";
if (roles["@type"] === "Admin") return "Admin";
return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`;
}
function rolesFromKey(key: string): UserRoles {
if (key === "Admin") return { "@type": "Admin" };
if (key.startsWith("custom:")) {
return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) };
}
return { "@type": "User" };
}
const gibOf = (bytes: number | undefined) => (bytes ? String(Math.round((bytes / GIB) * 10) / 10) : "");
const bytesOf = (gib: string) => {
const n = Number(gib.replace(",", "."));
return Number.isFinite(n) && n > 0 ? Math.round(n * GIB) : null;
};
/**
* One account, opened beside the list.
*
* A panel rather than a dialog, so the list stays visible and the next account
* is one click away. Saving sends one `x:Account/set` with only what changed;
* a password and a delete are their own calls, because each is a decision of
* its own and should never ride along with a renamed display name.
*/
export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = account === null;
const self = account ? isSelf(account, ctx) : false;
const locked = account ? outranks(perms, account, ctx.roles) : false;
const editable = creating ? can(perms, "Account", "Create") : can(perms, "Account", "Update") && !locked;
const [description, setDescription] = useState(account?.description ?? "");
const [name, setName] = useState("");
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
const [password, setPassword] = useState(() => (creating ? generatePassword() : ""));
const [role, setRole] = useState(roleKey(account?.roles));
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
}, [ctx.domains, domainId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
const roleOptions = useMemo(() => {
const options: { value: string; label: string }[] = [{ value: "User", label: t("User") }];
if (ADMIN_BASELINE.every((p) => perms.has(p)) || role === "Admin") options.push({ value: "Admin", label: t("Administrator") });
for (const r of ctx.roles?.values() ?? []) {
if (canGrantRole(perms, r.id, ctx.roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
}
if (!options.some((o) => o.value === role)) options.push({ value: role, label: account ? roleName(account, ctx.roles) : role });
return options;
}, [perms, ctx.roles, role, account]);
const run = async (work: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await work();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
};
const save = () =>
run(async () => {
if (!account) {
if (!name.trim() || !domainId) {
setError(t("An account needs an address."));
return;
}
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
toast.success(t("Created {address}", { address }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = {};
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateAccount(account.id, patch);
toast.success(t("Saved {address}", { address }));
onChanged();
});
const used = account?.usedDiskQuota ?? 0;
const limit = account?.quotas?.[DISK_QUOTA];
return (
<aside className="admin-sheet" aria-label={creating ? t("New account") : address}>
<div className="admin-sheet-head">
{account && <Avatar who={{ name: account.description || account.name, email: account.emailAddress }} />}
<div className="grow">
<h2 className="truncate">{creating ? t("New account") : account.description || account.name}</h2>
{account && <div className="hint truncate notranslate" translate="no">{account.emailAddress}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{locked && (
<p className="admin-notice warn">
<Lock size={16} aria-hidden="true" />
<span>{t("This account has permissions yours doesn't, so you can view it but not change it.")}</span>
</p>
)}
{!creating && !locked && !can(perms, "Account", "Update") && (
<p className="admin-notice">{t("Your role lets you view accounts but not change them.")}</p>
)}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-description">{t("Display name")}</label>
<input id="admin-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
</div>
{creating && (
<div className="field">
<label htmlFor="admin-name">{t("Address")}</label>
<div className="row admin-address">
<input id="admin-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
<span className="muted">@</span>
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{!ctx.domains.length && <span className="hint">{t("No domains are available to create an account on.")}</span>}
</div>
)}
<h3>{t("Sign-in")}</h3>
{creating ? (
<PasswordField value={password} onChange={setPassword} />
) : (
self ? (
// This session signs in with the password; changing it here would
// strand it. Settings re-seals the session as it changes, so that is
// the door for one's own.
<p className="hint" style={{ marginTop: 0 }}>
{tNode("Change your own password in {settings}.", { settings: <Link href="/settings/security">{t("Security & sessions")}</Link> })}
</p>
) : (
<PasswordReset account={account} disabled={!editable} onDone={onChanged} />
)
)}
{!creating && (
<>
<h3>{t("Other addresses")}</h3>
<Aliases aliases={aliases} setAliases={setAliases} editable={editable} domains={ctx.domains} defaultDomain={account.domainId} domainName={domainName} />
</>
)}
{!creating && (
<>
<h3>{t("Groups")}</h3>
<div className="row wrap gap-4">
{Object.keys(account.memberGroupIds ?? {}).length ? (
Object.keys(account.memberGroupIds ?? {}).map((id) => {
const g = ctx.groups.get(id);
return <span key={id} className="chip">{g ? g.description || g.name : id}</span>;
})
) : (
<span className="hint">{t("Not in any group")}</span>
)}
</div>
</>
)}
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable || self} onChange={(e) => setRole(e.target.value)}>
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p className="hint">
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
</p>
<h3>{t("Storage")}</h3>
{!creating && (
<p className="hint" style={{ marginTop: 0 }}>
{limit ? t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) }) : t("{used} · no limit", { used: formatSize(used) })}
</p>
)}
<div className="field">
<label htmlFor="admin-quota">{t("Limit in GB")}</label>
<input id="admin-quota" className="input admin-narrow" inputMode="decimal" value={quota} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuota(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Account", "Destroy") && (
<DeleteAccount account={account} blocked={self ? t("You can't delete the account you're signed in with.") : locked ? t("This account has permissions yours doesn't.") : null} onDeleted={onDeleted} />
)}
</div>
{editable && (
<div className="admin-sheet-foot">
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={busy || (creating && (!name || !domainId || !password))} onClick={() => void save()}>
{creating ? t("Create account") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function PasswordField({ value, onChange, id = "admin-password" }: { value: string; onChange: (v: string) => void; id?: string }) {
return (
<div className="field">
<label htmlFor={id}>{t("Password")}</label>
<div className="row">
<input id={id} className="input grow mono" value={value} autoComplete="new-password" spellCheck={false} onChange={(e) => onChange(e.target.value)} />
<button type="button" className="icon-btn" aria-label={t("Generate a password")} title={t("Generate a password")} onClick={() => onChange(generatePassword())}>
<Dices size={18} />
</button>
<button
type="button"
className="icon-btn"
aria-label={t("Copy")}
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
>
<Copy size={18} />
</button>
</div>
<span className="hint">{t("Pass it on some way other than email to this address.")}</span>
</div>
);
}
function PasswordReset({ account, disabled, onDone }: { account: DirectoryAccount; disabled: boolean; onDone: () => void }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const first = account.description?.split(" ")[0] || account.name;
if (!open) {
return (
<div>
{!hasPassword(account) && <p className="hint" style={{ marginTop: 0 }}>{t("This account has no password. It may sign in through a directory or single sign-on.")}</p>}
<button className="btn" disabled={disabled} onClick={() => { setValue(generatePassword()); setOpen(true); }}>
<KeyRound size={16} /> {t("Set a new password…")}
</button>
</div>
);
}
return (
<div>
<PasswordField id="admin-reset-password" value={value} onChange={setValue} />
<p className="hint">{t("{name} will be signed out of every app and device using the old password.", { name: first })}</p>
{error && <p className="admin-notice error" role="alert">{error}</p>}
<div className="row">
<button
className="btn btn-primary btn-sm"
disabled={busy || !value}
onClick={async () => {
setBusy(true);
setError(null);
try {
await updateAccount(account.id, passwordPatch(account, value));
toast.success(t("New password set for {address}", { address: account.emailAddress ?? account.name }));
setOpen(false);
onDone();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Set password")}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setOpen(false)}>{t("Cancel")}</button>
</div>
</div>
);
}
function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName }: {
aliases: EmailAlias[];
setAliases: (a: EmailAlias[]) => void;
editable: boolean;
domains: { id: string; name: string }[];
defaultDomain: string;
domainName: (id: string) => string;
}) {
const [local, setLocal] = useState("");
const [domain, setDomain] = useState(defaultDomain);
const add = () => {
const name = local.trim().toLowerCase();
if (!name || aliases.some((a) => a.name === name && a.domainId === domain)) return;
setAliases([...aliases, { enabled: true, name, domainId: domain }]);
setLocal("");
};
return (
<div>
<div className="row wrap gap-4">
{aliases.length ? (
aliases.map((a, i) => (
<span key={`${a.name}@${a.domainId}`} className="chip notranslate" translate="no">
{a.name}@{domainName(a.domainId) || "…"}
{editable && (
<button className="chip-x" aria-label={t("Remove {address}", { address: `${a.name}@${domainName(a.domainId)}` })} onClick={() => setAliases(aliases.filter((_, j) => j !== i))}>
<X size={12} />
</button>
)}
</span>
))
) : (
<span className="hint">{t("None")}</span>
)}
</div>
{editable && (
<div className="row admin-address mt-8">
<input className="input" aria-label={t("New address")} placeholder={t("another name")} value={local} spellCheck={false} onChange={(e) => setLocal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
<span className="muted">@</span>
<select className="input" aria-label={t("Domain")} value={domain} onChange={(e) => setDomain(e.target.value)}>
{(domains.some((d) => d.id === defaultDomain) ? domains : [{ id: defaultDomain, name: domainName(defaultDomain) || "…" }, ...domains]).map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
<button className="btn btn-sm" onClick={add} disabled={!local.trim()}>
<Plus size={14} /> {t("Add")}
</button>
</div>
)}
{editable && <p className="hint">{t("Mail to these addresses is delivered to this account. Changes apply when you save.")}</p>}
</div>
);
}
function DeleteAccount({ account, blocked, onDeleted }: { account: DirectoryAccount; blocked: string | null; onDeleted: () => void }) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const address = account.emailAddress ?? account.name;
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete account…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {address}?", { address })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyAccount(account.id);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Delete account")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Search, UserPlus, Users } from "lucide-react";
import { useSession } from "@/store/session";
import { STALWART_CAP } from "@/jmap/client";
import { can, type RoleDef } from "@/lib/adminAccess";
import {
describeDirectoryError,
getAccounts,
listDomains,
listGroups,
listRoles,
queryAccounts,
DISK_QUOTA,
type DirectoryAccount,
type DirectoryDomain,
} from "@/lib/adminDirectory";
import { formatSize } from "@/lib/format";
import { plural, t } from "@/lib/i18n";
import { Avatar, Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { AccountSheet } from "./AccountSheet";
const PAGE_SIZE = 50;
export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const session = useSession((s) => s.session);
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const [page, setPage] = useState<{ accounts: DirectoryAccount[]; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
// Typing is not a query per keystroke.
useEffect(() => {
const id = window.setTimeout(() => {
setQuery(text);
setPosition(0);
}, 250);
return () => window.clearTimeout(id);
}, [text]);
useEffect(() => {
let cancelled = false;
setError(null);
void (async () => {
try {
const q = await queryAccounts({ type: "User", text: query, position, limit: PAGE_SIZE });
const accounts = await getAccounts(q.ids);
if (!cancelled) setPage({ accounts, total: q.total });
} catch (err) {
if (!cancelled) {
setPage({ accounts: [], total: 0 });
setError(describeDirectoryError(err));
}
}
})();
return () => {
cancelled = true;
};
}, [query, position, reload]);
// The lists the account sheet picks from. Each is a nicety: without it the
// sheet falls back to what it can see, or offers less.
useEffect(() => {
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
void listGroups().then((list) => setGroups(new Map(list.map((g) => [g.id, g]))), () => setGroups(new Map()));
}, [perms, reload]);
// An account opened by address that is not on the page being shown.
useEffect(() => {
if (!selectedId || selectedId === "new" || page?.accounts.some((a) => a.id === selectedId)) {
setLoose(null);
return;
}
let cancelled = false;
void getAccounts([selectedId]).then(
([a]) => { if (!cancelled) setLoose(a ?? null); },
() => { if (!cancelled) setLoose(null); },
);
return () => {
cancelled = true;
};
}, [selectedId, page]);
const ctx: DirectoryContext = useMemo(() => {
const seen = new Map<string, DirectoryDomain>();
for (const a of page?.accounts ?? []) {
const domain = a.emailAddress?.split("@")[1];
if (domain && !seen.has(a.domainId)) seen.set(a.domainId, { id: a.domainId, name: domain });
}
const ownId = session?.primaryAccounts?.[STALWART_CAP];
return {
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
roles,
groups,
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
};
}, [page, serverDomains, roles, groups, session]);
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
const close = () => navigate("/admin/accounts");
const changed = () => setReload((n) => n + 1);
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Accounts")}</h1>
<p className="lead">{t("The people who sign in to mail on the domains you manage.")}</p>
</div>
{can(perms, "Account", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/accounts/new")}>
<UserPlus size={16} /> {t("New account")}
</button>
)}
</div>
<div className="admin-toolbar">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input
className="input"
type="search"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("Search by name or address")}
aria-label={t("Search accounts")}
/>
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{page === null ? (
<Spinner />
) : page.accounts.length === 0 ? (
!error && (
<Empty icon={<Users size={32} />} title={query ? t("No accounts match") : t("No accounts yet")}>
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
</Empty>
)
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Account")}</th>
<th>{t("Role")}</th>
<th>{t("Storage")}</th>
<th className="hide-mobile">{t("Groups")}</th>
</tr>
</thead>
<tbody>
{page.accounts.map((a) => (
<tr
key={a.id}
className={a.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/accounts/${a.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/accounts/${a.id}`);
}
}}
aria-label={t("Open {address}", { address: a.emailAddress ?? a.name })}
>
<td>
<div className="admin-who">
<Avatar who={{ name: a.description || a.name, email: a.emailAddress }} size="sm" />
<div className="grow">
<div className="admin-who-name truncate">
{a.description || a.name}
{isSelf(a, ctx) && <span className="badge muted">{t("You")}</span>}
</div>
<div className="hint truncate notranslate" translate="no">{a.emailAddress}</div>
</div>
</div>
</td>
<td><RoleLabel account={a} roles={ctx.roles} /></td>
<td><StorageMeter account={a} /></td>
<td className="hide-mobile muted">
<span className="truncate admin-groups">{groupNames(a, ctx.groups) || "—"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager position={position} shown={page.accounts.length} total={page.total} onMove={setPosition} />
</>
)}
{(selectedId === "new" || selected) && (
<AccountSheet
key={selectedId}
account={selectedId === "new" ? null : selected}
ctx={ctx}
onClose={close}
onChanged={changed}
onCreated={(id) => {
changed();
navigate(`/admin/accounts/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
);
}
function groupNames(a: DirectoryAccount, groups: Map<string, DirectoryAccount>): string {
return Object.keys(a.memberGroupIds ?? {})
.map((id) => groups.get(id))
.filter(Boolean)
.map((g) => g!.description || g!.name)
.join(", ");
}
function RoleLabel({ account, roles }: { account: DirectoryAccount; roles: Map<string, RoleDef> | null }) {
const kind = account.roles?.["@type"] ?? "User";
return <span className={`admin-role ${kind === "Admin" ? "admin" : kind === "Custom" ? "custom" : ""}`}>{roleName(account, roles)}</span>;
}
function StorageMeter({ account }: { account: DirectoryAccount }) {
const used = account.usedDiskQuota ?? 0;
const limit = account.quotas?.[DISK_QUOTA] ?? 0;
if (!limit) return <span className="muted small">{t("{used} · no limit", { used: formatSize(used) })}</span>;
const pct = Math.min(100, Math.round((used / limit) * 100));
return (
<div className="admin-meter" title={t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}>
<div className="quota-bar"><span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} /></div>
<span className="small muted">{t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) })}</span>
</div>
);
}
function Pager({ position, shown, total, onMove }: { position: number; shown: number; total: number; onMove: (p: number) => void }) {
if (total <= PAGE_SIZE && position === 0) {
return <p className="hint admin-count">{plural(total, { one: "{n} account", other: "{n} accounts" })}</p>;
}
return (
<div className="admin-pager">
<span className="hint">{t("{from}{to} of {total}", { from: position + 1, to: position + shown, total })}</span>
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => onMove(Math.max(0, position - PAGE_SIZE))}>
<ChevronLeft size={18} />
</button>
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + shown >= total} onClick={() => onMove(position + PAGE_SIZE)}>
<ChevronRight size={18} />
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Link, Redirect, useLocation } from "wouter";
import { ArrowLeft, User } from "lucide-react";
import { hasAdministration } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { AccountsAdmin } from "./AccountsAdmin";
import { usePermissions } from "./usePermissions";
/**
* Administration: what the signed-in account's Stalwart role lets it manage.
*
* Laid out like Settings, because it is the same kind of place -- a list of
* sections and the one that is open -- and on a phone it behaves the same way,
* the list first and a section on its own. Accounts is the only section so
* far; the nav is written as a list so the next one is an entry, not a rework.
*/
export function AdminView({ section, id }: { section?: string; id?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
// Typed in by hand, or a role taken away since the menu was drawn. Stalwart
// would refuse every call anyway; this spares the page of refusals.
if (!hasAdministration(perms)) return <Redirect to="/mail" />;
return (
<div className={`settings-layout admin-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label={t("Administration")}>
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Directory")}</span></div>
<Link href="/admin/accounts" className={`nav-item ${!section || section === "accounts" ? "active" : ""}`}>
<User size={18} />
<span className="nav-label">{t("Accounts")}</span>
</Link>
</nav>
<div className="settings-content admin-content">
{section && (
<button className="btn btn-ghost btn-sm admin-back" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/admin")}>
<ArrowLeft size={16} /> {t("Administration")}
</button>
)}
<AccountsAdmin selectedId={id} />
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryAccount } from "@/lib/adminDirectory";
import { AccountSheet } from "../AccountSheet";
import type { DirectoryContext } from "../directoryContext";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
});
}
const account = (over: Partial<DirectoryAccount>): DirectoryAccount => ({
id: "u1",
"@type": "User",
name: "ada",
domainId: "d1",
emailAddress: "[email protected]",
description: "Ada Lovelace",
roles: { "@type": "User" },
credentials: { "0": { "@type": "Password", secret: "[********]" } },
...over,
});
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: null, groups: new Map(), self: { ids: new Set(["self"]), address: "[email protected]" } };
const button = (host: HTMLElement, text: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
/**
* The guards that stand in for checks Stalwart does not make. A store test
* cannot see these: they are what the sheet renders, and what it leaves out.
*/
describe("the account sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => {
root.render(
<Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
</Router>,
);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows an account that outranks the viewer read-only, password included", async () => {
signIn(HELPDESK);
await render(account({ roles: { "@type": "Admin" } }));
expect(host.textContent).toContain("permissions yours doesn't");
expect(button(host, "Set a new password")?.disabled).toBe(true);
expect((host.querySelector("#admin-description") as HTMLInputElement).disabled).toBe(true);
expect(host.textContent).not.toContain("Save changes");
});
it("lets the same viewer edit an ordinary account, but not delete it", async () => {
signIn(HELPDESK);
await render(account({}));
expect(button(host, "Set a new password")?.disabled).toBe(false);
expect(host.textContent).toContain("Save changes");
expect(host.textContent).not.toContain("Delete account");
});
it("sends your own password to Settings, and keeps your role and account out of reach", async () => {
signIn([...HELPDESK, "sysAccountDestroy"]);
await render(account({ id: "self", emailAddress: "[email protected]" }));
expect(host.textContent).toContain("Change your own password in");
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined();
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
expect(button(host, "Delete account")?.disabled).toBe(true);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { RoleDef } from "@/lib/adminAccess";
import type { DirectoryAccount, DirectoryDomain } from "@/lib/adminDirectory";
import { t } from "@/lib/i18n";
export interface DirectoryContext {
/** Domains to offer. Read from the server when allowed, else seen on accounts. */
domains: DirectoryDomain[];
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
roles: Map<string, RoleDef> | null;
groups: Map<string, DirectoryAccount>;
/** Registry ids and addresses that are the signed-in account itself. */
self: { ids: Set<string>; address: string };
}
export function isSelf(a: Pick<DirectoryAccount, "id" | "emailAddress">, ctx: DirectoryContext): boolean {
return ctx.self.ids.has(a.id) || (!!a.emailAddress && a.emailAddress.toLowerCase() === ctx.self.address);
}
export function roleName(a: Pick<DirectoryAccount, "roles">, roles: Map<string, RoleDef> | null): string {
const r = a.roles;
if (!r || r["@type"] === "User") return t("User");
if (r["@type"] === "Admin") return t("Administrator");
const names = Object.keys(r.roleIds ?? {}).map((id) => roles?.get(id)?.description).filter(Boolean);
return names.length ? names.join(", ") : t("Custom role");
}
+17
View File
@@ -0,0 +1,17 @@
import { useMemo } from "react";
import { useSession } from "@/store/session";
import { permissionSet, type Permissions } from "@/lib/adminAccess";
/**
* The signed-in account's permissions, as a set, stable between renders.
*
* Keyed on the contents, not the array. The session is fetched again whenever
* a response carries a different session state, and each fetch brings a new
* array with the same names in it; a set rebuilt from identity would re-run
* everything that depends on it, whose requests could bring another refresh.
*/
export function usePermissions(): Permissions {
// An installation with administration off sends none; this is belt and braces.
const key = useSession((s) => (s.session?.ihasmail?.administration === false ? "" : (s.session?.ihasmail?.permissions ?? []).join(",")));
return useMemo(() => permissionSet(key ? key.split(",") : []), [key]);
}