Add Tenants to Administration, and let an account be put in one
A tenant is a separate organisation on one server: its own people, domains and limits, and an administrator who manages only what is in it. It gets a section under Access, gated by sysTenantQuery and sysTenantGet, with a notice on a server that does not report Enterprise, where anyone inside a tenant is held to an ordinary user's permissions. The panel edits the tenant's name, logo, role and limits. The logo is an https address, drawn through the image proxy the strict image policy requires, or an image data URL. Limits change one quotas/<name> pointer each, so the four ihasmail does not offer keep their values, and an empty field is no limit. The role is the most anyone inside can be allowed. Stalwart keeps no list on a tenant -- each account, group, domain, list and role names its own -- so what a tenant holds is counted with memberTenantId queries and shown against its limits. Domains are added and taken out from the tenant's panel, one memberTenantId change each; only a domain in no tenant can be added, and its accounts stay where they are. Delete is offered once every count reads zero. A tenant does nothing until someone administers it, so the account panel gains a Tenant choice for an administrator who can read tenants: an Administrator inside a tenant administers that tenant. Nobody moves their own account. The mock has a tenant holding a domain and an administrator, a spare domain to assign, memberTenantId filters on every query, and Stalwart's rule that only an administrator outside every tenant may move things into one. A test of taking a domain back out found that the mock's pointer handling dropped a top-level null instead of storing it, so nothing had ever been cleared that way; it stores null now, as the server reads it back. Nothing about tenants has been written on a live server: production has none. KNOWN-ISSUES says what was read from source. Thirty-nine new strings and one plural, in all nine catalogues.
This commit is contained in:
+31
-3
@@ -1260,6 +1260,35 @@ Its labels are English only, so ihasmail ships its own translation of every one
|
|||||||
of them, loaded only when the Roles screen opens; a permission added by a later
|
of them, loaded only when the Roles screen opens; a permission added by a later
|
||||||
Stalwart shows the server's English until it is translated.
|
Stalwart shows the server's English until it is translated.
|
||||||
|
|
||||||
|
## Tenants
|
||||||
|
|
||||||
|
A tenant is a separate organisation on the same server — its own people,
|
||||||
|
domains and limits, and an administrator who manages only what is in it. It is
|
||||||
|
a Stalwart Enterprise feature: on a server that does not report Enterprise the
|
||||||
|
page says that anyone inside a tenant has only an ordinary user's permissions.
|
||||||
|
For a role with `sysTenantQuery` and `sysTenantGet`, under Access:
|
||||||
|
|
||||||
|
- **List and search** tenants, with each one's storage and account limit.
|
||||||
|
- **Create and edit** a tenant's name, logo (an https address, drawn through the
|
||||||
|
image proxy, or an image data URL), role and limits — accounts, groups,
|
||||||
|
mailing lists, domains, roles, DKIM keys and storage. An empty limit is no
|
||||||
|
limit, and a limit ihasmail does not offer keeps whatever it had.
|
||||||
|
- **The tenant's role** is the most anyone inside it can be allowed: their own
|
||||||
|
roles are cut down to it.
|
||||||
|
- **What it holds** is counted, each against its limit. Stalwart keeps no list
|
||||||
|
on the tenant; each account, group, domain, list and role names its tenant,
|
||||||
|
so the counts are queries for those.
|
||||||
|
- **Domains** are added to a tenant, or taken out, from its panel. Only a domain
|
||||||
|
in no tenant can be added, and the accounts already on it stay where they
|
||||||
|
are.
|
||||||
|
- **An account's tenant** is chosen on the account's own panel, which is how a
|
||||||
|
tenant gets its first administrator: an Administrator inside a tenant
|
||||||
|
administers that tenant.
|
||||||
|
- **Delete** is offered once the tenant holds nothing.
|
||||||
|
|
||||||
|
Only an administrator outside every tenant can put anything into one; Stalwart
|
||||||
|
refuses anyone else, and inside a tenant it scopes every list to that tenant.
|
||||||
|
|
||||||
## Domains
|
## Domains
|
||||||
|
|
||||||
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||||
@@ -1318,9 +1347,8 @@ 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
|
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.
|
the meantime Stalwart refuses what is no longer allowed.
|
||||||
|
|
||||||
The dashboard, accounts, groups, mailing lists, roles and domains are the
|
The dashboard, accounts, groups, mailing lists, tenants, roles and domains are
|
||||||
sections so far. Tenants are Stalwart capabilities the same screen is
|
the sections so far. Beyond the dashboard's counts, managing queues, logs and
|
||||||
laid out to take. Beyond the dashboard's counts, managing queues, logs and
|
|
||||||
server settings is deliberately out of scope.
|
server settings is deliberately out of scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
|
|
||||||
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs towards refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` has not been fetched through ihasmail's server on production** — the route sends the session's Basic credential, which reaches every other endpoint, and the source serves the schema to any signed-in account; until it is seen working there the picker's fallback is a notice that the list could not be loaded.
|
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs towards refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` has not been fetched through ihasmail's server on production** — the route sends the session's Basic credential, which reaches every other endpoint, and the source serves the schema to any signed-in account; until it is seen working there the picker's fallback is a notice that the list could not be loaded.
|
||||||
|
|
||||||
|
- **Tenants were built from the 0.16.22 source, its schema and the mock; nothing about them has been written on a live server yet.** Production is Enterprise with no tenants. Read from source: `x:Tenant` is `name`, `logo`, `roles`, `permissions`, `quotas` (a map of `TenantStorageQuota` names) and computed `usedDiskQuota`; membership is `memberTenantId` on accounts, groups, domains, lists, roles and DKIM keys; only a caller outside every tenant may set it (`set.rs` passes `can_set_tenant` only when the token has no tenant, and anyone else gets "Cannot modify memberTenantId property"); a tenant administrator's queries are scoped to the tenant. Assumed, not confirmed: that a tenant still named by anything is refused as `objectIsLinked` — the panel offers the delete only once its counts are all zero, so this matters only if a count cannot be read.
|
||||||
|
|
||||||
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalogue's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
|
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalogue's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
|
||||||
|
|
||||||
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
|||||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||||
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||||
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; create groups and add or remove their members; create mailing lists and paste in their recipients; build roles from Stalwart's permissions, in every language ihasmail speaks; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; create groups and add or remove their members; create mailing lists and paste in their recipients; build roles from Stalwart's permissions, in every language ihasmail speaks; set up tenants with their own domains, limits and administrators; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||||
|
|
||||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet".
|
|||||||
|
|
||||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||||
|
|
||||||
- **Administration beyond accounts, groups, mailing lists, roles and domains.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
- **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.
|
||||||
|
|||||||
@@ -255,3 +255,34 @@ test("a permission name Stalwart does not know fails the whole change", () => {
|
|||||||
assert.equal(r.notUpdated?.r4?.type, "invalidPatch");
|
assert.equal(r.notUpdated?.r4?.type, "invalidPatch");
|
||||||
assert.deepEqual(r.notUpdated!.r4!.properties, ["enabledPermissions/notARealPermission"]);
|
assert.deepEqual(r.notUpdated!.r4!.properties, ["enabledPermissions/notARealPermission"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Tenants: what they hold is whatever names them, and only an administrator outside one may move things in. */
|
||||||
|
test("a tenant's members are found by memberTenantId, and it is kept while it has any", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const accounts = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberTenantId: "t1" }, calculateTotal: true, limit: 0 }) as { total: number };
|
||||||
|
const domains = dir.handlers["x:Domain/query"]!({ filter: { memberTenantId: "t1" }, calculateTotal: true }) as { ids: string[] };
|
||||||
|
assert.equal(accounts.total, 1);
|
||||||
|
assert.deepEqual(domains.ids, ["d3"]);
|
||||||
|
const refused = dir.handlers["x:Tenant/set"]!({ destroy: ["t1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||||
|
assert.equal(refused.notDestroyed?.t1?.type, "objectIsLinked");
|
||||||
|
assert.deepEqual([...new Set(refused.notDestroyed!.t1!.linkedObjects.map((l) => l.object))].sort(), ["Account", "Domain"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tenant is created with quotas, a domain moves into it, and an empty one is deleted", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const c = dir.handlers["x:Tenant/set"]!({ create: { n: { name: "Globex", quotas: { maxAccounts: 5, maxDiskQuota: 1024 } } } }) as { created: Record<string, { id: string }> };
|
||||||
|
const id = c.created.n!.id;
|
||||||
|
const bad = dir.handlers["x:Tenant/set"]!({ update: { [id]: { "quotas/maxWidgets": 3 } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||||
|
assert.equal(bad.notUpdated?.[id]?.type, "invalidPatch");
|
||||||
|
dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: id } } });
|
||||||
|
assert.equal((dir.handlers["x:Domain/query"]!({ filter: { memberTenantId: id }, calculateTotal: true }) as { total: number }).total, 1);
|
||||||
|
dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: null } } });
|
||||||
|
assert.deepEqual((dir.handlers["x:Tenant/set"]!({ destroy: [id] }) as { destroyed: string[] }).destroyed, [id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tenant administrator cannot move anything into a tenant", () => {
|
||||||
|
const dir = make("tenant-admin");
|
||||||
|
const r = dir.handlers["x:Domain/set"]!({ update: { d4: { memberTenantId: "t1" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||||
|
assert.equal(r.notUpdated?.d4?.type, "invalidPatch");
|
||||||
|
assert.match(r.notUpdated!.d4!.description, /memberTenantId/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
* history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off
|
* history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off
|
||||||
* refuses the history the way a Community server does.
|
* refuses the history the way a Community server does.
|
||||||
*
|
*
|
||||||
* What it does not reproduce is tenancy: every caller sees every record. The
|
* Tenants are there for a system administrator to manage -- one tenant holding a
|
||||||
* real server scopes a tenant administrator's queries, and nothing in the client
|
* domain and an account, `memberTenantId` filters on the queries, and the rule
|
||||||
|
* that only an administrator outside every tenant may move things into one. What
|
||||||
|
* it does not reproduce is a tenant administrator's scoping: every caller sees
|
||||||
|
* every record. The real server scopes those queries, and nothing in the client
|
||||||
* relies on seeing more or less than it is given.
|
* relies on seeing more or less than it is given.
|
||||||
*
|
*
|
||||||
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
|
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
|
||||||
@@ -102,7 +105,8 @@ export function createDirectory(opts: Options) {
|
|||||||
const domains: Obj[] = [
|
const domains: Obj[] = [
|
||||||
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
||||||
domain("d2", userDomain === "example.org" ? "example.net" : "example.org", { catchAllAddress: `postmaster@${userDomain}` }),
|
domain("d2", userDomain === "example.org" ? "example.net" : "example.org", { catchAllAddress: `postmaster@${userDomain}` }),
|
||||||
domain("d3", "old-brand.example", { ...managed(false, false, false), description: "No longer used", subAddressing: { "@type": "Custom", customRule: "..." } }),
|
domain("d3", "old-brand.example", { ...managed(false, false, false), description: "No longer used", subAddressing: { "@type": "Custom", customRule: "..." }, memberTenantId: "t1" }),
|
||||||
|
domain("d4", "spare.example", { description: "Waiting for a tenant" }),
|
||||||
];
|
];
|
||||||
const dkimKeys: Obj[] = [
|
const dkimKeys: Obj[] = [
|
||||||
{ id: "k1", "@type": "Dkim1Ed25519Sha256", domainId: "d1", selector: "v1-ed25519-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
{ id: "k1", "@type": "Dkim1Ed25519Sha256", domainId: "d1", selector: "v1-ed25519-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||||
@@ -151,8 +155,8 @@ export function createDirectory(opts: Options) {
|
|||||||
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
|
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 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 user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean; tenant?: string }) => {
|
||||||
const domainId = o.domain === "d2" ? "d2" : "d1";
|
const domainId = o.domain === "d2" || o.domain === "d3" ? o.domain : "d1";
|
||||||
const row: Obj = {
|
const row: Obj = {
|
||||||
id: o.id ?? `u${counter++}`,
|
id: o.id ?? `u${counter++}`,
|
||||||
"@type": "User",
|
"@type": "User",
|
||||||
@@ -162,7 +166,7 @@ export function createDirectory(opts: Options) {
|
|||||||
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
|
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"),
|
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
memberGroupIds: flags(o.groups ?? []),
|
memberGroupIds: flags(o.groups ?? []),
|
||||||
memberTenantId: null,
|
memberTenantId: o.tenant ?? null,
|
||||||
roles: o.roles ?? { "@type": "User" },
|
roles: o.roles ?? { "@type": "User" },
|
||||||
permissions: { "@type": "Inherit" },
|
permissions: { "@type": "Inherit" },
|
||||||
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
|
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
|
||||||
@@ -183,6 +187,7 @@ export function createDirectory(opts: Options) {
|
|||||||
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
|
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: "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: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
|
||||||
|
user({ name: "wile", domain: "d3", description: "Wile E. Coyote", roles: { "@type": "Admin" }, used: 2.1, quota: 5, tenant: "t1" });
|
||||||
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
|
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: "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: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
|
||||||
@@ -221,6 +226,15 @@ export function createDirectory(opts: Options) {
|
|||||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
||||||
|
const tenants: Obj[] = [
|
||||||
|
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
|
||||||
|
];
|
||||||
|
const tenantUsage = (id: string) => accounts.filter((x) => x.memberTenantId === id).reduce((n, x) => n + Number(x.usedDiskQuota ?? 0), 0);
|
||||||
|
/** Only an administrator outside every tenant may put things in one; Stalwart refuses anyone else. */
|
||||||
|
const tenantRefused = (patch: Obj): Obj | null =>
|
||||||
|
"memberTenantId" in patch && opts.role !== "admin" ? setError("invalidPatch", "Cannot modify memberTenantId property", ["memberTenantId"]) : null;
|
||||||
|
|
||||||
const refuseMetrics = () => {
|
const refuseMetrics = () => {
|
||||||
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
|
if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart.");
|
||||||
};
|
};
|
||||||
@@ -323,7 +337,8 @@ export function createDirectory(opts: Options) {
|
|||||||
"x:Account/get": get(accounts, "sysAccountGet"),
|
"x:Account/get": get(accounts, "sysAccountGet"),
|
||||||
"x:Account/query": query(() => accounts, "sysAccountQuery", ["text", "@type", "domainId", "externalId", "memberGroupIds", "memberTenantId", "name"], (o, f) =>
|
"x:Account/query": query(() => accounts, "sysAccountQuery", ["text", "@type", "domainId", "externalId", "memberGroupIds", "memberTenantId", "name"], (o, f) =>
|
||||||
(f["@type"] === undefined || o["@type"] === f["@type"]) && (f.domainId === undefined || o.domainId === f.domainId) &&
|
(f["@type"] === undefined || o["@type"] === f["@type"]) && (f.domainId === undefined || o.domainId === f.domainId) &&
|
||||||
(f.memberGroupIds === undefined || Boolean((o.memberGroupIds as Obj | undefined)?.[f.memberGroupIds as string])) && matchText(o, f.text) && matchText(o, f.name)),
|
(f.memberGroupIds === undefined || Boolean((o.memberGroupIds as Obj | undefined)?.[f.memberGroupIds as string])) &&
|
||||||
|
(f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||||
"x:Account/set": (a) => {
|
"x:Account/set": (a) => {
|
||||||
const created: Obj = {};
|
const created: Obj = {};
|
||||||
const notCreated: Obj = {};
|
const notCreated: Obj = {};
|
||||||
@@ -339,6 +354,10 @@ export function createDirectory(opts: Options) {
|
|||||||
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); 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);
|
const refused = grantRefused(o.roles);
|
||||||
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
|
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
|
||||||
|
if (o.memberTenantId) {
|
||||||
|
const refusedTenant = tenantRefused(o);
|
||||||
|
if (refusedTenant) { notCreated[cid] = refusedTenant; continue; }
|
||||||
|
}
|
||||||
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
|
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
|
||||||
const weak = password ? weakPassword(password.secret) : null;
|
const weak = password ? weakPassword(password.secret) : null;
|
||||||
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
||||||
@@ -352,7 +371,7 @@ export function createDirectory(opts: Options) {
|
|||||||
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
|
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
|
||||||
const patch = raw as Obj;
|
const patch = raw as Obj;
|
||||||
const next = structuredClone(target);
|
const next = structuredClone(target);
|
||||||
let failure: Obj | null = null;
|
let failure: Obj | null = tenantRefused(patch);
|
||||||
for (const [path, value] of Object.entries(patch)) {
|
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 === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
|
||||||
if (path.endsWith("/secret")) {
|
if (path.endsWith("/secret")) {
|
||||||
@@ -404,7 +423,7 @@ export function createDirectory(opts: Options) {
|
|||||||
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 } : {}) };
|
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/get": get(domains, "sysDomainGet"),
|
||||||
"x:Domain/query": query(() => domains, "sysDomainQuery", ["text", "aliases", "memberTenantId", "name"], (o, f) => matchText(o, f.text) && matchText(o, f.name)),
|
"x:Domain/query": query(() => domains, "sysDomainQuery", ["text", "aliases", "memberTenantId", "name"], (o, f) => (f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||||
"x:Domain/set": (a) => {
|
"x:Domain/set": (a) => {
|
||||||
const created: Obj = {};
|
const created: Obj = {};
|
||||||
const notCreated: Obj = {};
|
const notCreated: Obj = {};
|
||||||
@@ -431,6 +450,9 @@ export function createDirectory(opts: Options) {
|
|||||||
demand("sysDomainUpdate");
|
demand("sysDomainUpdate");
|
||||||
const target = domains.find((d) => d.id === id);
|
const target = domains.find((d) => d.id === id);
|
||||||
if (!target) { notUpdated[id] = setError("notFound", "Domain not found."); continue; }
|
if (!target) { notUpdated[id] = setError("notFound", "Domain not found."); continue; }
|
||||||
|
const refusedTenant = tenantRefused(raw as Obj);
|
||||||
|
if (refusedTenant) { notUpdated[id] = refusedTenant; continue; }
|
||||||
|
if ((raw as Obj).memberTenantId && !tenants.some((x) => x.id === (raw as Obj).memberTenantId)) { notUpdated[id] = setError("invalidForeignKey", "Tenant does not exist.", ["memberTenantId"]); continue; }
|
||||||
const next = structuredClone(target);
|
const next = structuredClone(target);
|
||||||
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||||
// Live on 2026-09-13: a catch-all that is not a whole address.
|
// Live on 2026-09-13: a catch-all that is not a whole address.
|
||||||
@@ -486,7 +508,7 @@ export function createDirectory(opts: Options) {
|
|||||||
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
|
(!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a);
|
||||||
},
|
},
|
||||||
"x:MailingList/get": get(lists, "sysMailingListGet"),
|
"x:MailingList/get": get(lists, "sysMailingListGet"),
|
||||||
"x:MailingList/query": query(() => lists, "sysMailingListQuery", ["text", "memberTenantId"], (o, f) => matchText(o, f.text)),
|
"x:MailingList/query": query(() => lists, "sysMailingListQuery", ["text", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || o.memberTenantId === f.memberTenantId) && matchText(o, f.text)),
|
||||||
"x:MailingList/set": (a) => {
|
"x:MailingList/set": (a) => {
|
||||||
const created: Obj = {};
|
const created: Obj = {};
|
||||||
const notCreated: Obj = {};
|
const notCreated: Obj = {};
|
||||||
@@ -601,8 +623,65 @@ export function createDirectory(opts: Options) {
|
|||||||
}
|
}
|
||||||
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 } : {}) };
|
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:Tenant/get": (a) => {
|
||||||
|
demand("sysTenantGet");
|
||||||
|
for (const x of tenants) x.usedDiskQuota = tenantUsage(x.id as string);
|
||||||
|
return get(tenants, "sysTenantGet")(a);
|
||||||
|
},
|
||||||
|
"x:Tenant/query": query(() => tenants, "sysTenantQuery", ["text"], (o, f) => matchText(o, f.text)),
|
||||||
|
"x:Tenant/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
const notDestroyed: Obj = {};
|
||||||
|
const check = (o: Obj): Obj | null => {
|
||||||
|
if (typeof o.name !== "string" || !o.name.trim()) return setError("invalidProperties", "String cannot be empty", ["name"]);
|
||||||
|
for (const [k, v] of Object.entries((o.quotas as Obj) ?? {})) {
|
||||||
|
if (!["maxAccounts", "maxGroups", "maxDomains", "maxMailingLists", "maxRoles", "maxOauthClients", "maxDkimKeys", "maxDnsServers", "maxDirectories", "maxAcmeProviders", "maxDiskQuota"].includes(k) || typeof v !== "number" || v < 0) {
|
||||||
|
return setError("invalidProperties", "Invalid value for object property", [`quotas/${k}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grantRefused(o.roles) ? setError("forbidden", grantRefused(o.roles)!) : null;
|
||||||
|
};
|
||||||
|
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
demand("sysTenantCreate");
|
||||||
|
const o: Obj = { logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, ...(raw as Obj) };
|
||||||
|
const failure = check(o);
|
||||||
|
if (failure) { notCreated[cid] = failure; continue; }
|
||||||
|
const id = `t${counter++}`;
|
||||||
|
tenants.push({ ...o, id, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") });
|
||||||
|
created[cid] = { id };
|
||||||
|
}
|
||||||
|
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
demand("sysTenantUpdate");
|
||||||
|
const target = tenants.find((x) => x.id === id);
|
||||||
|
if (!target) { notUpdated[id] = setError("notFound", "Tenant not found."); continue; }
|
||||||
|
const next = structuredClone(target);
|
||||||
|
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||||
|
const failure = check(next);
|
||||||
|
if (failure) { notUpdated[id] = failure.type === "invalidProperties" ? { ...failure, type: "invalidPatch" } : failure; continue; }
|
||||||
|
Object.assign(target, next);
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
demand("sysTenantDestroy");
|
||||||
|
if (!tenants.some((x) => x.id === id)) { notDestroyed[id] = setError("notFound", "Tenant not found."); continue; }
|
||||||
|
const linked = [
|
||||||
|
...accounts.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Account", id: x.id })),
|
||||||
|
...domains.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Domain", id: x.id })),
|
||||||
|
...lists.filter((x) => x.memberTenantId === id).map((x) => ({ object: "MailingList", id: x.id })),
|
||||||
|
...roles.filter((x) => x.memberTenantId === id).map((x) => ({ object: "Role", id: x.id })),
|
||||||
|
];
|
||||||
|
if (linked.length) { notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Tenant", id }, linkedObjects: linked }; continue; }
|
||||||
|
tenants.splice(tenants.findIndex((x) => x.id === id), 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:Role/get": get(roles, "sysRoleGet"),
|
"x:Role/get": get(roles, "sysRoleGet"),
|
||||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId) && matchText(o, f.description)),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { handlers, permissions: [...permissions], accounts };
|
return { handlers, permissions: [...permissions], accounts };
|
||||||
@@ -629,6 +708,9 @@ function setPointer(obj: Obj, path: string, value: unknown): void {
|
|||||||
node = node[part] as Obj;
|
node = node[part] as Obj;
|
||||||
}
|
}
|
||||||
const last = parts[parts.length - 1]!;
|
const last = parts[parts.length - 1]!;
|
||||||
if (value === null) delete node[last];
|
// A top-level property set to null reads back as null -- deleting it here
|
||||||
|
// would leave the old value in place when the change is merged back. A
|
||||||
|
// nested pointer to null takes the entry out of its set or map.
|
||||||
|
if (value === null && parts.length > 1) delete node[last];
|
||||||
else node[last] = value;
|
else node[last] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
|
||||||
|
|
||||||
|
describe("a tenant's limits", () => {
|
||||||
|
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
|
||||||
|
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
|
||||||
|
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
|
||||||
|
"quotas/maxAccounts": 30,
|
||||||
|
"quotas/maxDomains": null,
|
||||||
|
"quotas/maxGroups": 5,
|
||||||
|
});
|
||||||
|
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what a tenant holds", () => {
|
||||||
|
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
|
||||||
|
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
|
||||||
|
const f = (args as { filter: Record<string, unknown> }).filter;
|
||||||
|
if (method === "x:Role/query") throw new Error("forbidden");
|
||||||
|
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
|
||||||
|
});
|
||||||
|
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1 });
|
||||||
|
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
|
||||||
|
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
|
||||||
|
call.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a domain in and out by its memberTenantId", async () => {
|
||||||
|
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
|
||||||
|
await setDomainTenant("d4", "t1");
|
||||||
|
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
|
||||||
|
await setDomainTenant("d4", null);
|
||||||
|
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
|
||||||
|
call.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a tenant's logo", () => {
|
||||||
|
it("is drawn only from https or an image data URL", () => {
|
||||||
|
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
|
||||||
|
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
|
||||||
|
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
|
||||||
|
expect(drawableLogo("javascript:alert(1)")).toBeNull();
|
||||||
|
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
|
||||||
|
expect(drawableLogo(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
|
|||||||
return perms.has(`sys${object}${op}`);
|
return perms.has(`sys${object}${op}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "roles" | "domains";
|
export type AdminSection = "dashboard" | "accounts" | "groups" | "lists" | "tenants" | "roles" | "domains";
|
||||||
|
|
||||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ export function adminSections(perms: Permissions): AdminSection[] {
|
|||||||
// Groups are accounts to the server, behind the same two permissions.
|
// Groups are accounts to the server, behind the same two permissions.
|
||||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
|
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts", "groups");
|
||||||
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
|
if (can(perms, "MailingList", "Query") && can(perms, "MailingList", "Get")) out.push("lists");
|
||||||
|
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) out.push("tenants");
|
||||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
|
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) out.push("roles");
|
||||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface DirectoryAccount {
|
|||||||
usedDiskQuota?: number;
|
usedDiskQuota?: number;
|
||||||
aliases?: Record<string, EmailAlias>;
|
aliases?: Record<string, EmailAlias>;
|
||||||
memberGroupIds?: Record<string, boolean>;
|
memberGroupIds?: Record<string, boolean>;
|
||||||
|
/** The tenant the account belongs to; only ever read back to an administrator outside every tenant. */
|
||||||
|
memberTenantId?: string | null;
|
||||||
credentials?: Record<string, Credential>;
|
credentials?: Record<string, Credential>;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ export interface DirectoryDomain {
|
|||||||
|
|
||||||
const ACCOUNT_PROPERTIES = [
|
const ACCOUNT_PROPERTIES = [
|
||||||
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
||||||
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
|
"usedDiskQuota", "aliases", "memberGroupIds", "memberTenantId", "credentials", "createdAt",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The one quota ihasmail edits; the others keep whatever they had. */
|
/** The one quota ihasmail edits; the others keep whatever they had. */
|
||||||
@@ -143,6 +145,8 @@ export interface NewAccount {
|
|||||||
password: string;
|
password: string;
|
||||||
roles: UserRoles;
|
roles: UserRoles;
|
||||||
diskQuotaBytes: number | null;
|
diskQuotaBytes: number | null;
|
||||||
|
/** Put the account in a tenant; only an administrator outside every tenant may. */
|
||||||
|
memberTenantId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccount(input: NewAccount): Promise<string> {
|
export async function createAccount(input: NewAccount): Promise<string> {
|
||||||
@@ -159,6 +163,7 @@ export async function createAccount(input: NewAccount): Promise<string> {
|
|||||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||||
aliases: {},
|
aliases: {},
|
||||||
memberGroupIds: {},
|
memberGroupIds: {},
|
||||||
|
...(input.memberTenantId ? { memberTenantId: input.memberTenantId } : {}),
|
||||||
// Required on create. Turning it on is one-way and not offered here.
|
// Required on create. Turning it on is one-way and not offered here.
|
||||||
encryptionAtRest: { "@type": "Disabled" },
|
encryptionAtRest: { "@type": "Disabled" },
|
||||||
},
|
},
|
||||||
@@ -229,7 +234,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** What kind of thing a refusal was about, where the wording has to differ. */
|
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||||
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role";
|
export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" | "tenant";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Say what went wrong in terms of the person's own action, in their language.
|
* Say what went wrong in terms of the person's own action, in their language.
|
||||||
@@ -282,7 +287,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
|||||||
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
? t("Your organisation has reached the number of mailing lists it is allowed.")
|
||||||
: object === "role"
|
: object === "role"
|
||||||
? t("Your organisation has reached the number of roles it is allowed.")
|
? t("Your organisation has reached the number of roles it is allowed.")
|
||||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
: object === "tenant"
|
||||||
|
? t("The server allows no more tenants.")
|
||||||
|
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||||
case "objectIsLinked":
|
case "objectIsLinked":
|
||||||
return t("Something still depends on this, so the server kept it.");
|
return t("Something still depends on this, so the server kept it.");
|
||||||
case "notFound":
|
case "notFound":
|
||||||
@@ -294,7 +301,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
|||||||
? t("This mailing list no longer exists. Someone may have deleted it.")
|
? t("This mailing list no longer exists. Someone may have deleted it.")
|
||||||
: object === "role"
|
: object === "role"
|
||||||
? t("This role no longer exists. Someone may have deleted it.")
|
? t("This role no longer exists. Someone may have deleted it.")
|
||||||
: t("This account no longer exists. Someone may have deleted it.");
|
: object === "tenant"
|
||||||
|
? t("This tenant no longer exists. Someone may have deleted it.")
|
||||||
|
: t("This account no longer exists. Someone may have deleted it.");
|
||||||
case "rateLimit":
|
case "rateLimit":
|
||||||
return t("Too many attempts. Please wait a few minutes and try again.");
|
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||||
case "tooLarge":
|
case "tooLarge":
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export function describeLinked(linked: string[]): string {
|
|||||||
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
||||||
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
||||||
else if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
|
else if (kind === "Role") parts.push(plural(n, { one: "{n} role", other: "{n} roles" }));
|
||||||
|
else if (kind === "Domain") parts.push(plural(n, { one: "{n} domain", other: "{n} domains" }));
|
||||||
else if (kind === "Authentication") parts.push(t("the default roles"));
|
else if (kind === "Authentication") parts.push(t("the default roles"));
|
||||||
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import { DirectoryError } from "@/lib/adminDirectory";
|
||||||
|
import { DomainError } from "@/lib/adminDomains";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenants, from Stalwart 0.16's directory.
|
||||||
|
*
|
||||||
|
* `x:Tenant` behind `sysTenant*`, an Enterprise feature: on a Community server
|
||||||
|
* the objects exist, but anyone inside a tenant is held to a plain user's
|
||||||
|
* permissions. A tenant is a name, an optional logo, the roles its members may
|
||||||
|
* at most have, and quotas. It holds no list of what is in it -- membership
|
||||||
|
* runs the other way, as `memberTenantId` on accounts, groups, domains,
|
||||||
|
* mailing lists, roles and DKIM keys.
|
||||||
|
*
|
||||||
|
* Only an account outside every tenant may set `memberTenantId` (Stalwart
|
||||||
|
* refuses "Cannot modify memberTenantId property" to anyone else), and inside a
|
||||||
|
* tenant the server scopes every query to it and fills it in on create. Shapes
|
||||||
|
* from the 0.16.22 schema:
|
||||||
|
*
|
||||||
|
* - `quotas` is a map from a `TenantStorageQuota` name to a number: counts for
|
||||||
|
* accounts, groups, domains and the rest, bytes for `maxDiskQuota`. A quota
|
||||||
|
* that is absent is no limit.
|
||||||
|
* - `logo` is a URL or a data URL, or null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TenantRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||||
|
|
||||||
|
export interface DirectoryTenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo?: string | null;
|
||||||
|
roles?: TenantRoles;
|
||||||
|
quotas?: Record<string, number>;
|
||||||
|
usedDiskQuota?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quotas ihasmail offers, in the order they are shown. Disk space is bytes; the rest are counts. */
|
||||||
|
export const TENANT_QUOTAS = ["maxAccounts", "maxGroups", "maxMailingLists", "maxDomains", "maxRoles", "maxDkimKeys", "maxDiskQuota"] as const;
|
||||||
|
export type TenantQuota = (typeof TENANT_QUOTAS)[number];
|
||||||
|
|
||||||
|
/** What belongs to a tenant, and how each is counted. */
|
||||||
|
export const TENANT_MEMBERS = [
|
||||||
|
{ key: "accounts", method: "x:Account/query", filter: { "@type": "User" }, quota: "maxAccounts" },
|
||||||
|
{ key: "groups", method: "x:Account/query", filter: { "@type": "Group" }, quota: "maxGroups" },
|
||||||
|
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
|
||||||
|
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
|
||||||
|
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
|
||||||
|
] as const;
|
||||||
|
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
|
||||||
|
|
||||||
|
const TENANT_PROPERTIES = ["name", "logo", "roles", "quotas", "usedDiskQuota", "createdAt"];
|
||||||
|
|
||||||
|
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null> | undefined> & {
|
||||||
|
created?: Record<string, { id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||||
|
const first = Object.values(res[key] ?? {})[0];
|
||||||
|
if (first) throw new DomainError(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryTenants(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||||
|
const res = await client.call<{ ids?: string[]; total?: number }>("x:Tenant/query", {
|
||||||
|
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
|
||||||
|
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 getTenants(ids: string[]): Promise<DirectoryTenant[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const res = await client.call<{ list: DirectoryTenant[] }>("x:Tenant/get", { ids, properties: TENANT_PROPERTIES });
|
||||||
|
const byId = new Map(res.list.map((x) => [x.id, x]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((x): x is DirectoryTenant => Boolean(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tenant's id and name, for pickers. */
|
||||||
|
export async function listTenantNames(): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
const q = await client.call<{ ids?: string[] }>("x:Tenant/query", { limit: client.maxObjectsInGet });
|
||||||
|
if (!q.ids?.length) return [];
|
||||||
|
const res = await client.call<{ list: Array<{ id: string; name: string }> }>("x:Tenant/get", { ids: q.ids, properties: ["name"] });
|
||||||
|
return res.list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of each kind of thing a tenant holds. A count that fails -- the
|
||||||
|
* viewer may not read that kind at all -- is left out rather than shown as
|
||||||
|
* none, which would read as "safe to delete".
|
||||||
|
*/
|
||||||
|
export async function countTenantMembers(tenantId: string): Promise<Partial<Record<TenantMemberKind, number>>> {
|
||||||
|
const out: Partial<Record<TenantMemberKind, number>> = {};
|
||||||
|
await Promise.all(
|
||||||
|
TENANT_MEMBERS.map(async (m) => {
|
||||||
|
try {
|
||||||
|
const res = await client.call<{ total?: number }>(m.method, { filter: { ...m.filter, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
|
||||||
|
if (typeof res.total === "number") out[m.key] = res.total;
|
||||||
|
} catch {
|
||||||
|
/* left out */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The domains in a tenant, and those in none, which are the ones that can be added. */
|
||||||
|
export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> }> {
|
||||||
|
const q = await client.call<{ ids?: string[] }>("x:Domain/query", { limit: client.maxObjectsInGet });
|
||||||
|
if (!q.ids?.length) return { inTenant: [], unassigned: [] };
|
||||||
|
const res = await client.call<{ list: Array<{ id: string; name: string; memberTenantId?: string | null }> }>("x:Domain/get", { ids: q.ids, properties: ["name", "memberTenantId"] });
|
||||||
|
const sorted = res.list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return {
|
||||||
|
inTenant: sorted.filter((d) => d.memberTenantId === tenantId).map(({ id, name }) => ({ id, name })),
|
||||||
|
unassigned: sorted.filter((d) => !d.memberTenantId).map(({ id, name }) => ({ id, name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put a domain in a tenant, or take it out with null. */
|
||||||
|
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
|
||||||
|
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
|
||||||
|
throwIfRefused(res, "notUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewTenant {
|
||||||
|
name: string;
|
||||||
|
logo: string | null;
|
||||||
|
roles: TenantRoles;
|
||||||
|
quotas: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(input: NewTenant): Promise<string> {
|
||||||
|
const res = await client.call<SetResponse>("x:Tenant/set", {
|
||||||
|
create: { n: { name: input.name.trim(), logo: input.logo, roles: input.roles, permissions: { "@type": "Inherit" }, quotas: input.quotas } },
|
||||||
|
});
|
||||||
|
throwIfRefused(res, "notCreated");
|
||||||
|
const id = res.created?.n?.id;
|
||||||
|
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the tenant was created."));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!Object.keys(patch).length) return;
|
||||||
|
const res = await client.call<SetResponse>("x:Tenant/set", { update: { [id]: patch } });
|
||||||
|
throwIfRefused(res, "notUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyTenant(id: string): Promise<void> {
|
||||||
|
const res = await client.call<SetResponse>("x:Tenant/set", { destroy: [id] });
|
||||||
|
throwIfRefused(res, "notDestroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quota changes as one pointer each, so a quota ihasmail does not offer
|
||||||
|
* (OAuth clients, DNS servers, directories, ACME providers) keeps its value.
|
||||||
|
*/
|
||||||
|
export function quotasPatch(before: Record<string, number> | undefined, after: Partial<Record<TenantQuota, number | null>>): Record<string, number | null> {
|
||||||
|
const patch: Record<string, number | null> = {};
|
||||||
|
for (const key of TENANT_QUOTAS) {
|
||||||
|
if (!(key in after)) continue;
|
||||||
|
const next = after[key] ?? null;
|
||||||
|
const was = before?.[key] ?? null;
|
||||||
|
if (next !== was) patch[`quotas/${key}`] = next;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logo worth showing: an https or data image URL. Anything else is kept but not drawn. */
|
||||||
|
export function drawableLogo(logo: string | null | undefined): string | null {
|
||||||
|
if (!logo) return null;
|
||||||
|
return /^https:\/\//i.test(logo) || /^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(logo) ? logo : null;
|
||||||
|
}
|
||||||
@@ -259,6 +259,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Diese Rolle existiert nicht mehr. Jemand hat sie möglicherweise gelöscht.",
|
"This role no longer exists. Someone may have deleted it.": "Diese Rolle existiert nicht mehr. Jemand hat sie möglicherweise gelöscht.",
|
||||||
"the default roles": "den Standardrollen",
|
"the default roles": "den Standardrollen",
|
||||||
"The server did not say whether the role was created.": "Der Server hat nicht mitgeteilt, ob die Rolle angelegt wurde.",
|
"The server did not say whether the role was created.": "Der Server hat nicht mitgeteilt, ob die Rolle angelegt wurde.",
|
||||||
|
"No tenant": "Kein Mandant",
|
||||||
|
"You can't move your own account into a tenant.": "Sie können Ihr eigenes Konto nicht in einen Mandanten verschieben.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Ein Konto in einem Mandanten ist durch dessen Rolle begrenzt und zählt zu dessen Limits, und Administrator bedeutet Administrator dieses Mandanten.",
|
||||||
|
"Tenants": "Mandanten",
|
||||||
|
"Storage in GB": "Speicher in GB",
|
||||||
|
"Default tenant roles": "Standardrollen für Mandanten",
|
||||||
|
"A tenant needs a name.": "Ein Mandant braucht einen Namen.",
|
||||||
|
"New tenant": "Neuer Mandant",
|
||||||
|
"{used} used": "{used} belegt",
|
||||||
|
"Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.",
|
||||||
|
"Logo": "Logo",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Stalwart zeigt es den Personen des Mandanten dort, wo es ein Logo zeigt.",
|
||||||
|
"What it holds": "Enthält",
|
||||||
|
"{n} of {limit}": "{n} von {limit}",
|
||||||
|
"Limits": "Limits",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
|
||||||
|
"Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.",
|
||||||
|
"Create tenant": "Mandant anlegen",
|
||||||
|
"Added {domain} to {tenant}": "{domain} zu {tenant} hinzugefügt",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} aus {tenant} entfernt",
|
||||||
|
"Take {domain} out of the tenant": "{domain} aus dem Mandanten entfernen",
|
||||||
|
"No domains in this tenant yet": "Noch keine Domains in diesem Mandanten",
|
||||||
|
"Domain to add": "Hinzuzufügende Domain",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Nur Domains ohne Mandanten können hinzugefügt werden. Die Konten auf einer Domain bleiben, wo sie sind; verschieben Sie jedes in seinem eigenen Bereich.",
|
||||||
|
"An empty tenant can be deleted.": "Ein leerer Mandant kann gelöscht werden.",
|
||||||
|
"Delete tenant…": "Mandant löschen…",
|
||||||
|
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
|
||||||
|
"Delete tenant": "Mandant löschen",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Mandanten sind eine Funktion von Stalwart Enterprise. Dieser Server meldet kein Enterprise, daher hat jeder in einem Mandanten nur die Berechtigungen eines normalen Benutzers.",
|
||||||
|
"Search tenants": "Mandanten durchsuchen",
|
||||||
|
"No tenants match": "Keine passenden Mandanten",
|
||||||
|
"No tenants yet": "Noch keine Mandanten",
|
||||||
|
"Account limit": "Kontenlimit",
|
||||||
|
"The server allows no more tenants.": "Der Server erlaubt keine weiteren Mandanten.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Dieser Mandant existiert nicht mehr. Jemand hat ihn möglicherweise gelöscht.",
|
||||||
|
"The server did not say whether the tenant was created.": "Der Server hat nicht mitgeteilt, ob der Mandant angelegt wurde.",
|
||||||
"User": "Benutzer",
|
"User": "Benutzer",
|
||||||
"Administrator": "Administrator",
|
"Administrator": "Administrator",
|
||||||
"Custom role": "Eigene Rolle",
|
"Custom role": "Eigene Rolle",
|
||||||
@@ -1676,6 +1715,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} Empfänger", other: "{n} Empfänger" },
|
"{n} recipients": { one: "{n} Empfänger", other: "{n} Empfänger" },
|
||||||
"Grants {n} permissions": { one: "Gewährt {n} Berechtigung", other: "Gewährt {n} Berechtigungen" },
|
"Grants {n} permissions": { one: "Gewährt {n} Berechtigung", other: "Gewährt {n} Berechtigungen" },
|
||||||
"{n} roles": { one: "{n} Rolle", other: "{n} Rollen" },
|
"{n} roles": { one: "{n} Rolle", other: "{n} Rollen" },
|
||||||
|
"{n} tenants": { one: "{n} Mandant", other: "{n} Mandanten" },
|
||||||
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
|
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
|
||||||
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
|
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -251,6 +251,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Este rol ya no existe. Puede que alguien lo haya eliminado.",
|
"This role no longer exists. Someone may have deleted it.": "Este rol ya no existe. Puede que alguien lo haya eliminado.",
|
||||||
"the default roles": "los roles predeterminados",
|
"the default roles": "los roles predeterminados",
|
||||||
"The server did not say whether the role was created.": "El servidor no indicó si el rol se creó.",
|
"The server did not say whether the role was created.": "El servidor no indicó si el rol se creó.",
|
||||||
|
"No tenant": "Sin inquilino",
|
||||||
|
"You can't move your own account into a tenant.": "No puede mover su propia cuenta a un inquilino.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Una cuenta de un inquilino está limitada por el rol del inquilino y cuenta para sus límites, y Administrador significa administrador de ese inquilino.",
|
||||||
|
"Tenants": "Inquilinos",
|
||||||
|
"Storage in GB": "Almacenamiento en GB",
|
||||||
|
"Default tenant roles": "Roles de inquilino predeterminados",
|
||||||
|
"A tenant needs a name.": "Un inquilino necesita un nombre.",
|
||||||
|
"New tenant": "Nuevo inquilino",
|
||||||
|
"{used} used": "{used} usados",
|
||||||
|
"Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.",
|
||||||
|
"Logo": "Logotipo",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL data de una imagen. Stalwart la muestra a las personas del inquilino donde muestra un logotipo.",
|
||||||
|
"What it holds": "Contenido",
|
||||||
|
"{n} of {limit}": "{n} de {limit}",
|
||||||
|
"Limits": "Límites",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
|
||||||
|
"Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.",
|
||||||
|
"Create tenant": "Crear inquilino",
|
||||||
|
"Added {domain} to {tenant}": "{domain} añadido a {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} quitado de {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "Quitar {domain} del inquilino",
|
||||||
|
"No domains in this tenant yet": "Aún no hay dominios en este inquilino",
|
||||||
|
"Domain to add": "Dominio para añadir",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Solo se pueden añadir dominios que no estén en ningún inquilino. Las cuentas que ya están en un dominio se quedan donde están; mueva cada una desde su propio panel.",
|
||||||
|
"An empty tenant can be deleted.": "Un inquilino vacío se puede eliminar.",
|
||||||
|
"Delete tenant…": "Eliminar inquilino…",
|
||||||
|
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
|
||||||
|
"Delete tenant": "Eliminar inquilino",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Los inquilinos son una función de Stalwart Enterprise. Este servidor no indica Enterprise, así que cualquiera dentro de un inquilino solo tiene los permisos de un usuario normal.",
|
||||||
|
"Search tenants": "Buscar inquilinos",
|
||||||
|
"No tenants match": "Ningún inquilino coincide",
|
||||||
|
"No tenants yet": "Aún no hay inquilinos",
|
||||||
|
"Account limit": "Límite de cuentas",
|
||||||
|
"The server allows no more tenants.": "El servidor no permite más inquilinos.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Este inquilino ya no existe. Puede que alguien lo haya eliminado.",
|
||||||
|
"The server did not say whether the tenant was created.": "El servidor no indicó si el inquilino se creó.",
|
||||||
"User": "Usuario",
|
"User": "Usuario",
|
||||||
"Administrator": "Administrador",
|
"Administrator": "Administrador",
|
||||||
"Custom role": "Rol personalizado",
|
"Custom role": "Rol personalizado",
|
||||||
@@ -1649,6 +1688,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} destinatario", other: "{n} destinatarios" },
|
"{n} recipients": { one: "{n} destinatario", other: "{n} destinatarios" },
|
||||||
"Grants {n} permissions": { one: "Concede {n} permiso", other: "Concede {n} permisos" },
|
"Grants {n} permissions": { one: "Concede {n} permiso", other: "Concede {n} permisos" },
|
||||||
"{n} roles": { one: "{n} rol", other: "{n} roles" },
|
"{n} roles": { one: "{n} rol", other: "{n} roles" },
|
||||||
|
"{n} tenants": { one: "{n} inquilino", other: "{n} inquilinos" },
|
||||||
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
|
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
|
||||||
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
|
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -256,6 +256,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Ce rôle n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
"This role no longer exists. Someone may have deleted it.": "Ce rôle n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||||
"the default roles": "les rôles par défaut",
|
"the default roles": "les rôles par défaut",
|
||||||
"The server did not say whether the role was created.": "Le serveur n’a pas indiqué si le rôle a été créé.",
|
"The server did not say whether the role was created.": "Le serveur n’a pas indiqué si le rôle a été créé.",
|
||||||
|
"No tenant": "Aucun locataire",
|
||||||
|
"You can't move your own account into a tenant.": "Vous ne pouvez pas déplacer votre propre compte dans un locataire.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Un compte dans un locataire est limité par le rôle du locataire et compte dans ses limites, et Administrateur signifie administrateur de ce locataire.",
|
||||||
|
"Tenants": "Locataires",
|
||||||
|
"Storage in GB": "Stockage en Go",
|
||||||
|
"Default tenant roles": "Rôles de locataire par défaut",
|
||||||
|
"A tenant needs a name.": "Un locataire a besoin d’un nom.",
|
||||||
|
"New tenant": "Nouveau locataire",
|
||||||
|
"{used} used": "{used} utilisés",
|
||||||
|
"Your role lets you view tenants but not change them.": "Votre rôle vous permet de consulter les locataires, mais pas de les modifier.",
|
||||||
|
"Logo": "Logo",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Une adresse https ou une URL data d’image. Stalwart l’affiche aux personnes du locataire là où il affiche un logo.",
|
||||||
|
"What it holds": "Contenu",
|
||||||
|
"{n} of {limit}": "{n} sur {limit}",
|
||||||
|
"Limits": "Limites",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart refuse de créer au-delà d’une limite. Un champ vide signifie aucune limite.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Le maximum autorisé à quiconque dans ce locataire : ses propres rôles sont réduits à ce que ceux-ci accordent. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.",
|
||||||
|
"Checking what is still in this tenant…": "Vérification de ce que contient encore ce locataire…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "Il contient encore des comptes, des domaines ou d’autres éléments. Déplacez-les d’abord.",
|
||||||
|
"Create tenant": "Créer le locataire",
|
||||||
|
"Added {domain} to {tenant}": "{domain} ajouté à {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} retiré de {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "Retirer {domain} du locataire",
|
||||||
|
"No domains in this tenant yet": "Aucun domaine dans ce locataire pour l’instant",
|
||||||
|
"Domain to add": "Domaine à ajouter",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Seuls les domaines hors de tout locataire peuvent être ajoutés. Les comptes déjà sur un domaine restent où ils sont ; déplacez chacun depuis son propre panneau.",
|
||||||
|
"An empty tenant can be deleted.": "Un locataire vide peut être supprimé.",
|
||||||
|
"Delete tenant…": "Supprimer le locataire…",
|
||||||
|
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les d’abord.",
|
||||||
|
"Delete tenant": "Supprimer le locataire",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Les locataires sont une fonctionnalité de Stalwart Enterprise. Ce serveur ne se déclare pas Enterprise : toute personne dans un locataire n’a donc que les autorisations d’un utilisateur ordinaire.",
|
||||||
|
"Search tenants": "Rechercher des locataires",
|
||||||
|
"No tenants match": "Aucun locataire ne correspond",
|
||||||
|
"No tenants yet": "Aucun locataire pour l’instant",
|
||||||
|
"Account limit": "Limite de comptes",
|
||||||
|
"The server allows no more tenants.": "Le serveur n’autorise pas d’autres locataires.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Ce locataire n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||||
|
"The server did not say whether the tenant was created.": "Le serveur n’a pas indiqué si le locataire a été créé.",
|
||||||
"User": "Utilisateur",
|
"User": "Utilisateur",
|
||||||
"Administrator": "Administrateur",
|
"Administrator": "Administrateur",
|
||||||
"Custom role": "Rôle personnalisé",
|
"Custom role": "Rôle personnalisé",
|
||||||
@@ -1654,6 +1693,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} destinataire", other: "{n} destinataires" },
|
"{n} recipients": { one: "{n} destinataire", other: "{n} destinataires" },
|
||||||
"Grants {n} permissions": { one: "Accorde {n} autorisation", other: "Accorde {n} autorisations" },
|
"Grants {n} permissions": { one: "Accorde {n} autorisation", other: "Accorde {n} autorisations" },
|
||||||
"{n} roles": { one: "{n} rôle", other: "{n} rôles" },
|
"{n} roles": { one: "{n} rôle", other: "{n} rôles" },
|
||||||
|
"{n} tenants": { one: "{n} locataire", other: "{n} locataires" },
|
||||||
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
|
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
|
||||||
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
|
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -250,6 +250,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "このロールはもう存在しません。誰かが削除した可能性があります。",
|
"This role no longer exists. Someone may have deleted it.": "このロールはもう存在しません。誰かが削除した可能性があります。",
|
||||||
"the default roles": "既定のロール設定",
|
"the default roles": "既定のロール設定",
|
||||||
"The server did not say whether the role was created.": "ロールが作成されたかどうか、サーバーから返答がありませんでした。",
|
"The server did not say whether the role was created.": "ロールが作成されたかどうか、サーバーから返答がありませんでした。",
|
||||||
|
"No tenant": "テナントなし",
|
||||||
|
"You can't move your own account into a tenant.": "自分のアカウントをテナントに移すことはできません。",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "テナント内のアカウントは、テナントのロールによって制限され、テナントの上限に数えられます。また、管理者はそのテナントの管理者を意味します。",
|
||||||
|
"Tenants": "テナント",
|
||||||
|
"Storage in GB": "ストレージ (GB)",
|
||||||
|
"Default tenant roles": "テナントの既定ロール",
|
||||||
|
"A tenant needs a name.": "テナントには名前が必要です。",
|
||||||
|
"New tenant": "新しいテナント",
|
||||||
|
"{used} used": "{used} 使用中",
|
||||||
|
"Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。",
|
||||||
|
"Logo": "ロゴ",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https のアドレス、または画像の data URL です。Stalwart はロゴを表示する場所で、テナントの利用者に表示します。",
|
||||||
|
"What it holds": "含まれるもの",
|
||||||
|
"{n} of {limit}": "{limit} 件中 {n} 件",
|
||||||
|
"Limits": "上限",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart は上限を超える作成を拒否します。空欄は上限なしです。",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。",
|
||||||
|
"Checking what is still in this tenant…": "このテナントに残っているものを確認しています…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。",
|
||||||
|
"Create tenant": "テナントを作成",
|
||||||
|
"Added {domain} to {tenant}": "{domain} を {tenant} に追加しました",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} を {tenant} から外しました",
|
||||||
|
"Take {domain} out of the tenant": "{domain} をテナントから外す",
|
||||||
|
"No domains in this tenant yet": "このテナントにはまだドメインがありません",
|
||||||
|
"Domain to add": "追加するドメイン",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "追加できるのは、どのテナントにも属していないドメインだけです。ドメイン上の既存のアカウントはそのまま残るため、それぞれのパネルから移動してください。",
|
||||||
|
"An empty tenant can be deleted.": "空のテナントは削除できます。",
|
||||||
|
"Delete tenant…": "テナントを削除…",
|
||||||
|
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
|
||||||
|
"Delete tenant": "テナントを削除",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "テナントは Stalwart Enterprise の機能です。このサーバーは Enterprise と報告していないため、テナント内の利用者は通常のユーザーの権限しか持ちません。",
|
||||||
|
"Search tenants": "テナントを検索",
|
||||||
|
"No tenants match": "一致するテナントはありません",
|
||||||
|
"No tenants yet": "まだテナントがありません",
|
||||||
|
"Account limit": "アカウント上限",
|
||||||
|
"The server allows no more tenants.": "サーバーはこれ以上のテナントを許可していません。",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "このテナントはもう存在しません。誰かが削除した可能性があります。",
|
||||||
|
"The server did not say whether the tenant was created.": "テナントが作成されたかどうか、サーバーから返答がありませんでした。",
|
||||||
"User": "ユーザー",
|
"User": "ユーザー",
|
||||||
"Administrator": "管理者",
|
"Administrator": "管理者",
|
||||||
"Custom role": "カスタムロール",
|
"Custom role": "カスタムロール",
|
||||||
@@ -1657,6 +1696,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { other: "{n} 件の受信者" },
|
"{n} recipients": { other: "{n} 件の受信者" },
|
||||||
"Grants {n} permissions": { other: "{n} 件の権限を付与" },
|
"Grants {n} permissions": { other: "{n} 件の権限を付与" },
|
||||||
"{n} roles": { other: "{n} 件のロール" },
|
"{n} roles": { other: "{n} 件のロール" },
|
||||||
|
"{n} tenants": { other: "{n} 件のテナント" },
|
||||||
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
|
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
|
||||||
"{n} other items": { other: "その他 {n} 件" },
|
"{n} other items": { other: "その他 {n} 件" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -247,6 +247,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Deze rol bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
|
"This role no longer exists. Someone may have deleted it.": "Deze rol bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
|
||||||
"the default roles": "de standaardrollen",
|
"the default roles": "de standaardrollen",
|
||||||
"The server did not say whether the role was created.": "De server heeft niet gemeld of de rol is aangemaakt.",
|
"The server did not say whether the role was created.": "De server heeft niet gemeld of de rol is aangemaakt.",
|
||||||
|
"No tenant": "Geen tenant",
|
||||||
|
"You can't move your own account into a tenant.": "U kunt uw eigen account niet naar een tenant verplaatsen.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Een account in een tenant wordt beperkt door de rol van de tenant en telt mee voor de limieten, en Beheerder betekent beheerder van die tenant.",
|
||||||
|
"Tenants": "Tenants",
|
||||||
|
"Storage in GB": "Opslag in GB",
|
||||||
|
"Default tenant roles": "Standaardrollen voor tenants",
|
||||||
|
"A tenant needs a name.": "Een tenant heeft een naam nodig.",
|
||||||
|
"New tenant": "Nieuwe tenant",
|
||||||
|
"{used} used": "{used} gebruikt",
|
||||||
|
"Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.",
|
||||||
|
"Logo": "Logo",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. Stalwart toont het aan de mensen van de tenant waar het een logo toont.",
|
||||||
|
"What it holds": "Inhoud",
|
||||||
|
"{n} of {limit}": "{n} van {limit}",
|
||||||
|
"Limits": "Limieten",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
|
||||||
|
"Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.",
|
||||||
|
"Create tenant": "Tenant aanmaken",
|
||||||
|
"Added {domain} to {tenant}": "{domain} aan {tenant} toegevoegd",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} uit {tenant} gehaald",
|
||||||
|
"Take {domain} out of the tenant": "{domain} uit de tenant halen",
|
||||||
|
"No domains in this tenant yet": "Nog geen domeinen in deze tenant",
|
||||||
|
"Domain to add": "Toe te voegen domein",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Alleen domeinen die in geen enkele tenant zitten kunnen worden toegevoegd. De accounts op een domein blijven waar ze zijn; verplaats elk vanuit het eigen paneel.",
|
||||||
|
"An empty tenant can be deleted.": "Een lege tenant kan worden verwijderd.",
|
||||||
|
"Delete tenant…": "Tenant verwijderen…",
|
||||||
|
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
|
||||||
|
"Delete tenant": "Tenant verwijderen",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Tenants zijn een functie van Stalwart Enterprise. Deze server meldt geen Enterprise, dus iedereen in een tenant heeft alleen de rechten van een gewone gebruiker.",
|
||||||
|
"Search tenants": "Tenants zoeken",
|
||||||
|
"No tenants match": "Geen tenants gevonden",
|
||||||
|
"No tenants yet": "Nog geen tenants",
|
||||||
|
"Account limit": "Accountlimiet",
|
||||||
|
"The server allows no more tenants.": "De server staat geen tenants meer toe.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Deze tenant bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
|
||||||
|
"The server did not say whether the tenant was created.": "De server heeft niet gemeld of de tenant is aangemaakt.",
|
||||||
"User": "Gebruiker",
|
"User": "Gebruiker",
|
||||||
"Administrator": "Beheerder",
|
"Administrator": "Beheerder",
|
||||||
"Custom role": "Aangepaste rol",
|
"Custom role": "Aangepaste rol",
|
||||||
@@ -1645,6 +1684,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} ontvanger", other: "{n} ontvangers" },
|
"{n} recipients": { one: "{n} ontvanger", other: "{n} ontvangers" },
|
||||||
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
|
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
|
||||||
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
|
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
|
||||||
|
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
|
||||||
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
||||||
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -254,6 +254,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Esta função não existe mais. Alguém pode tê-la excluído.",
|
"This role no longer exists. Someone may have deleted it.": "Esta função não existe mais. Alguém pode tê-la excluído.",
|
||||||
"the default roles": "as funções padrão",
|
"the default roles": "as funções padrão",
|
||||||
"The server did not say whether the role was created.": "O servidor não informou se a função foi criada.",
|
"The server did not say whether the role was created.": "O servidor não informou se a função foi criada.",
|
||||||
|
"No tenant": "Nenhum locatário",
|
||||||
|
"You can't move your own account into a tenant.": "Você não pode mover sua própria conta para um locatário.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Uma conta em um locatário é limitada pela função do locatário e conta para os limites dele, e Administrador significa administrador desse locatário.",
|
||||||
|
"Tenants": "Locatários",
|
||||||
|
"Storage in GB": "Armazenamento em GB",
|
||||||
|
"Default tenant roles": "Funções padrão de locatário",
|
||||||
|
"A tenant needs a name.": "Um locatário precisa de um nome.",
|
||||||
|
"New tenant": "Novo locatário",
|
||||||
|
"{used} used": "{used} usados",
|
||||||
|
"Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.",
|
||||||
|
"Logo": "Logotipo",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL data de uma imagem. O Stalwart a mostra às pessoas do locatário onde mostra um logotipo.",
|
||||||
|
"What it holds": "O que contém",
|
||||||
|
"{n} of {limit}": "{n} de {limit}",
|
||||||
|
"Limits": "Limites",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "O Stalwart se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.",
|
||||||
|
"Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.",
|
||||||
|
"Create tenant": "Criar locatário",
|
||||||
|
"Added {domain} to {tenant}": "{domain} adicionado a {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} retirado de {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "Retirar {domain} do locatário",
|
||||||
|
"No domains in this tenant yet": "Nenhum domínio neste locatário ainda",
|
||||||
|
"Domain to add": "Domínio a adicionar",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Só podem ser adicionados domínios que não estejam em nenhum locatário. As contas já existentes num domínio ficam onde estão; mova cada uma pelo próprio painel.",
|
||||||
|
"An empty tenant can be deleted.": "Um locatário vazio pode ser excluído.",
|
||||||
|
"Delete tenant…": "Excluir locatário…",
|
||||||
|
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
|
||||||
|
"Delete tenant": "Excluir locatário",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Locatários são um recurso do Stalwart Enterprise. Este servidor não informa ser Enterprise, então qualquer pessoa num locatário tem só as permissões de um usuário comum.",
|
||||||
|
"Search tenants": "Pesquisar locatários",
|
||||||
|
"No tenants match": "Nenhum locatário corresponde",
|
||||||
|
"No tenants yet": "Nenhum locatário ainda",
|
||||||
|
"Account limit": "Limite de contas",
|
||||||
|
"The server allows no more tenants.": "O servidor não permite mais locatários.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Este locatário não existe mais. Alguém pode tê-lo excluído.",
|
||||||
|
"The server did not say whether the tenant was created.": "O servidor não informou se o locatário foi criado.",
|
||||||
"User": "Usuário",
|
"User": "Usuário",
|
||||||
"Administrator": "Administrador",
|
"Administrator": "Administrador",
|
||||||
"Custom role": "Função personalizada",
|
"Custom role": "Função personalizada",
|
||||||
@@ -1652,6 +1691,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} destinatário", other: "{n} destinatários" },
|
"{n} recipients": { one: "{n} destinatário", other: "{n} destinatários" },
|
||||||
"Grants {n} permissions": { one: "Concede {n} permissão", other: "Concede {n} permissões" },
|
"Grants {n} permissions": { one: "Concede {n} permissão", other: "Concede {n} permissões" },
|
||||||
"{n} roles": { one: "{n} função", other: "{n} funções" },
|
"{n} roles": { one: "{n} função", other: "{n} funções" },
|
||||||
|
"{n} tenants": { one: "{n} locatário", other: "{n} locatários" },
|
||||||
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
|
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
|
||||||
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
|
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -253,6 +253,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Этой роли больше нет. Возможно, её кто-то удалил.",
|
"This role no longer exists. Someone may have deleted it.": "Этой роли больше нет. Возможно, её кто-то удалил.",
|
||||||
"the default roles": "настройки ролей по умолчанию",
|
"the default roles": "настройки ролей по умолчанию",
|
||||||
"The server did not say whether the role was created.": "Сервер не сообщил, создана ли роль.",
|
"The server did not say whether the role was created.": "Сервер не сообщил, создана ли роль.",
|
||||||
|
"No tenant": "Без арендатора",
|
||||||
|
"You can't move your own account into a tenant.": "Нельзя переместить собственную учётную запись в арендатора.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Учётная запись арендатора ограничена ролью арендатора и учитывается в его лимитах, а «Администратор» означает администратора этого арендатора.",
|
||||||
|
"Tenants": "Арендаторы",
|
||||||
|
"Storage in GB": "Хранилище, ГБ",
|
||||||
|
"Default tenant roles": "Роли арендатора по умолчанию",
|
||||||
|
"A tenant needs a name.": "Арендатору нужно название.",
|
||||||
|
"New tenant": "Новый арендатор",
|
||||||
|
"{used} used": "Занято {used}",
|
||||||
|
"Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.",
|
||||||
|
"Logo": "Логотип",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Stalwart показывает его людям арендатора там, где показывает логотип.",
|
||||||
|
"What it holds": "Содержимое",
|
||||||
|
"{n} of {limit}": "{n} из {limit}",
|
||||||
|
"Limits": "Лимиты",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не даёт создать больше, чем позволяет лимит. Пустое поле — без лимита.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.",
|
||||||
|
"Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.",
|
||||||
|
"Create tenant": "Создать арендатора",
|
||||||
|
"Added {domain} to {tenant}": "{domain} добавлен в {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} убран из {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "Убрать {domain} из арендатора",
|
||||||
|
"No domains in this tenant yet": "У этого арендатора пока нет доменов",
|
||||||
|
"Domain to add": "Домен для добавления",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Добавить можно только домены, не принадлежащие ни одному арендатору. Учётные записи на домене остаются на месте; переносите каждую из её собственной панели.",
|
||||||
|
"An empty tenant can be deleted.": "Пустого арендатора можно удалить.",
|
||||||
|
"Delete tenant…": "Удалить арендатора…",
|
||||||
|
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
|
||||||
|
"Delete tenant": "Удалить арендатора",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Арендаторы — функция Stalwart Enterprise. Этот сервер не сообщает о редакции Enterprise, поэтому у любого в арендаторе только разрешения обычного пользователя.",
|
||||||
|
"Search tenants": "Поиск арендаторов",
|
||||||
|
"No tenants match": "Нет подходящих арендаторов",
|
||||||
|
"No tenants yet": "Арендаторов пока нет",
|
||||||
|
"Account limit": "Лимит учётных записей",
|
||||||
|
"The server allows no more tenants.": "Сервер не позволяет больше арендаторов.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Этого арендатора больше нет. Возможно, его кто-то удалил.",
|
||||||
|
"The server did not say whether the tenant was created.": "Сервер не сообщил, создан ли арендатор.",
|
||||||
"User": "Пользователь",
|
"User": "Пользователь",
|
||||||
"Administrator": "Администратор",
|
"Administrator": "Администратор",
|
||||||
"Custom role": "Особая роль",
|
"Custom role": "Особая роль",
|
||||||
@@ -1651,6 +1690,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} получатель", few: "{n} получателя", many: "{n} получателей", other: "{n} получателя" },
|
"{n} recipients": { one: "{n} получатель", few: "{n} получателя", many: "{n} получателей", other: "{n} получателя" },
|
||||||
"Grants {n} permissions": { one: "Даёт {n} разрешение", few: "Даёт {n} разрешения", many: "Даёт {n} разрешений", other: "Даёт {n} разрешения" },
|
"Grants {n} permissions": { one: "Даёт {n} разрешение", few: "Даёт {n} разрешения", many: "Даёт {n} разрешений", other: "Даёт {n} разрешения" },
|
||||||
"{n} roles": { one: "{n} роль", few: "{n} роли", many: "{n} ролей", other: "{n} роли" },
|
"{n} roles": { one: "{n} роль", few: "{n} роли", many: "{n} ролей", other: "{n} роли" },
|
||||||
|
"{n} tenants": { one: "{n} арендатор", few: "{n} арендатора", many: "{n} арендаторов", other: "{n} арендатора" },
|
||||||
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
|
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
|
||||||
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
|
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -247,6 +247,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "Цієї ролі більше немає. Можливо, її хтось видалив.",
|
"This role no longer exists. Someone may have deleted it.": "Цієї ролі більше немає. Можливо, її хтось видалив.",
|
||||||
"the default roles": "налаштування ролей за замовчуванням",
|
"the default roles": "налаштування ролей за замовчуванням",
|
||||||
"The server did not say whether the role was created.": "Сервер не повідомив, чи створено роль.",
|
"The server did not say whether the role was created.": "Сервер не повідомив, чи створено роль.",
|
||||||
|
"No tenant": "Без орендаря",
|
||||||
|
"You can't move your own account into a tenant.": "Не можна перемістити власний обліковий запис до орендаря.",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Обліковий запис орендаря обмежений роллю орендаря й зараховується до його лімітів, а «Адміністратор» означає адміністратора цього орендаря.",
|
||||||
|
"Tenants": "Орендарі",
|
||||||
|
"Storage in GB": "Сховище, ГБ",
|
||||||
|
"Default tenant roles": "Ролі орендаря за замовчуванням",
|
||||||
|
"A tenant needs a name.": "Орендарю потрібна назва.",
|
||||||
|
"New tenant": "Новий орендар",
|
||||||
|
"{used} used": "Зайнято {used}",
|
||||||
|
"Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.",
|
||||||
|
"Logo": "Логотип",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Stalwart показує його людям орендаря там, де показує логотип.",
|
||||||
|
"What it holds": "Вміст",
|
||||||
|
"{n} of {limit}": "{n} з {limit}",
|
||||||
|
"Limits": "Ліміти",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не дає створити більше, ніж дозволяє ліміт. Порожнє поле — без ліміту.",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.",
|
||||||
|
"Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.",
|
||||||
|
"Create tenant": "Створити орендаря",
|
||||||
|
"Added {domain} to {tenant}": "{domain} додано до {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "{domain} прибрано з {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "Прибрати {domain} з орендаря",
|
||||||
|
"No domains in this tenant yet": "У цього орендаря поки немає доменів",
|
||||||
|
"Domain to add": "Домен для додавання",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Додати можна лише домени, що не належать жодному орендарю. Облікові записи на домені лишаються на місці; переносьте кожен з його власної панелі.",
|
||||||
|
"An empty tenant can be deleted.": "Порожнього орендаря можна видалити.",
|
||||||
|
"Delete tenant…": "Видалити орендаря…",
|
||||||
|
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
|
||||||
|
"Delete tenant": "Видалити орендаря",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Орендарі — функція Stalwart Enterprise. Цей сервер не повідомляє про редакцію Enterprise, тож будь-хто в орендарі має лише дозволи звичайного користувача.",
|
||||||
|
"Search tenants": "Пошук орендарів",
|
||||||
|
"No tenants match": "Немає відповідних орендарів",
|
||||||
|
"No tenants yet": "Орендарів поки немає",
|
||||||
|
"Account limit": "Ліміт облікових записів",
|
||||||
|
"The server allows no more tenants.": "Сервер не дозволяє більше орендарів.",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "Цього орендаря більше немає. Можливо, його хтось видалив.",
|
||||||
|
"The server did not say whether the tenant was created.": "Сервер не повідомив, чи створено орендаря.",
|
||||||
"User": "Користувач",
|
"User": "Користувач",
|
||||||
"Administrator": "Адміністратор",
|
"Administrator": "Адміністратор",
|
||||||
"Custom role": "Власна роль",
|
"Custom role": "Власна роль",
|
||||||
@@ -1645,6 +1684,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { one: "{n} одержувач", few: "{n} одержувачі", many: "{n} одержувачів", other: "{n} одержувача" },
|
"{n} recipients": { one: "{n} одержувач", few: "{n} одержувачі", many: "{n} одержувачів", other: "{n} одержувача" },
|
||||||
"Grants {n} permissions": { one: "Надає {n} дозвіл", few: "Надає {n} дозволи", many: "Надає {n} дозволів", other: "Надає {n} дозволу" },
|
"Grants {n} permissions": { one: "Надає {n} дозвіл", few: "Надає {n} дозволи", many: "Надає {n} дозволів", other: "Надає {n} дозволу" },
|
||||||
"{n} roles": { one: "{n} роль", few: "{n} ролі", many: "{n} ролей", other: "{n} ролі" },
|
"{n} roles": { one: "{n} роль", few: "{n} ролі", many: "{n} ролей", other: "{n} ролі" },
|
||||||
|
"{n} tenants": { one: "{n} орендар", few: "{n} орендарі", many: "{n} орендарів", other: "{n} орендаря" },
|
||||||
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
|
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
|
||||||
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
|
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -249,6 +249,45 @@ export const catalog: Catalog = {
|
|||||||
"This role no longer exists. Someone may have deleted it.": "此角色已不存在。可能已被他人删除。",
|
"This role no longer exists. Someone may have deleted it.": "此角色已不存在。可能已被他人删除。",
|
||||||
"the default roles": "默认角色设置",
|
"the default roles": "默认角色设置",
|
||||||
"The server did not say whether the role was created.": "服务器未说明角色是否已创建。",
|
"The server did not say whether the role was created.": "服务器未说明角色是否已创建。",
|
||||||
|
"No tenant": "无租户",
|
||||||
|
"You can't move your own account into a tenant.": "您不能将自己的账户移入租户。",
|
||||||
|
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "租户中的账户受租户角色限制,并计入租户的限额;管理员指该租户的管理员。",
|
||||||
|
"Tenants": "租户",
|
||||||
|
"Storage in GB": "存储 (GB)",
|
||||||
|
"Default tenant roles": "默认租户角色",
|
||||||
|
"A tenant needs a name.": "租户需要一个名称。",
|
||||||
|
"New tenant": "新建租户",
|
||||||
|
"{used} used": "已用 {used}",
|
||||||
|
"Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。",
|
||||||
|
"Logo": "徽标",
|
||||||
|
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https 地址或图片的 data URL。Stalwart 会在显示徽标的地方向租户成员展示它。",
|
||||||
|
"What it holds": "包含内容",
|
||||||
|
"{n} of {limit}": "{n}/{limit}",
|
||||||
|
"Limits": "限额",
|
||||||
|
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart 会拒绝超出限额的创建。留空表示不限。",
|
||||||
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。",
|
||||||
|
"Checking what is still in this tenant…": "正在检查此租户中还有什么…",
|
||||||
|
"It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。",
|
||||||
|
"Create tenant": "创建租户",
|
||||||
|
"Added {domain} to {tenant}": "已将 {domain} 添加到 {tenant}",
|
||||||
|
"Took {domain} out of {tenant}": "已将 {domain} 移出 {tenant}",
|
||||||
|
"Take {domain} out of the tenant": "将 {domain} 移出租户",
|
||||||
|
"No domains in this tenant yet": "此租户中还没有域名",
|
||||||
|
"Domain to add": "要添加的域名",
|
||||||
|
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "只能添加不属于任何租户的域名。域名上已有的账户保持不变;请在各自的面板中移动。",
|
||||||
|
"An empty tenant can be deleted.": "空租户可以删除。",
|
||||||
|
"Delete tenant…": "删除租户…",
|
||||||
|
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
|
||||||
|
"Delete tenant": "删除租户",
|
||||||
|
"Separate organisations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
|
||||||
|
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "租户是 Stalwart Enterprise 的功能。此服务器未报告为 Enterprise,因此租户中的任何人都只有普通用户的权限。",
|
||||||
|
"Search tenants": "搜索租户",
|
||||||
|
"No tenants match": "没有匹配的租户",
|
||||||
|
"No tenants yet": "还没有租户",
|
||||||
|
"Account limit": "账户限额",
|
||||||
|
"The server allows no more tenants.": "服务器不允许再创建租户。",
|
||||||
|
"This tenant no longer exists. Someone may have deleted it.": "此租户已不存在。可能已被他人删除。",
|
||||||
|
"The server did not say whether the tenant was created.": "服务器未说明租户是否已创建。",
|
||||||
"User": "用户",
|
"User": "用户",
|
||||||
"Administrator": "管理员",
|
"Administrator": "管理员",
|
||||||
"Custom role": "自定义角色",
|
"Custom role": "自定义角色",
|
||||||
@@ -1656,6 +1695,7 @@ export const catalog: Catalog = {
|
|||||||
"{n} recipients": { other: "{n} 位收件人" },
|
"{n} recipients": { other: "{n} 位收件人" },
|
||||||
"Grants {n} permissions": { other: "授予 {n} 项权限" },
|
"Grants {n} permissions": { other: "授予 {n} 项权限" },
|
||||||
"{n} roles": { other: "{n} 个角色" },
|
"{n} roles": { other: "{n} 个角色" },
|
||||||
|
"{n} tenants": { other: "{n} 个租户" },
|
||||||
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
|
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
|
||||||
"{n} other items": { other: "其他 {n} 项" },
|
"{n} other items": { other: "其他 {n} 项" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1788,6 +1788,9 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.admin-perm-rows li.granted { box-shadow: inset 3px 0 0 var(--accent); }
|
.admin-perm-rows li.granted { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.admin-perm-rows .mono { font-family: var(--font-mono); font-size: .8em; word-break: break-all; }
|
.admin-perm-rows .mono { font-family: var(--font-mono); font-size: .8em; word-break: break-all; }
|
||||||
.admin-perm-state { width: auto; min-width: 110px; flex: none; }
|
.admin-perm-state { width: auto; min-width: 110px; flex: none; }
|
||||||
|
.admin-tenant-logo { width: 40px; height: 40px; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); flex: none; }
|
||||||
|
.admin-tenant-logo.sm { width: 24px; height: 24px; }
|
||||||
|
.admin-quota-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 0 12px; }
|
||||||
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
|
.admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; }
|
||||||
.admin-kv dt { color: var(--fg-muted); }
|
.admin-kv dt { color: var(--fg-muted); }
|
||||||
.admin-kv dd { margin: 0; }
|
.admin-kv dd { margin: 0; }
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
const [role, setRole] = useState(roleKey(account?.roles));
|
const [role, setRole] = useState(roleKey(account?.roles));
|
||||||
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
|
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
|
||||||
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
|
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
|
||||||
|
const [tenantId, setTenantId] = useState(account?.memberTenantId ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
setError(t("An account needs an address."));
|
setError(t("An account needs an address."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
|
const id = await createAccount({ name, domainId, description, password, roles: rolesFromKey(role), diskQuotaBytes: bytesOf(quota), memberTenantId: tenantId || null });
|
||||||
toast.success(t("Created {address}", { address }));
|
toast.success(t("Created {address}", { address }));
|
||||||
onCreated(id);
|
onCreated(id);
|
||||||
return;
|
return;
|
||||||
@@ -140,6 +141,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
|
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
|
||||||
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
|
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
|
||||||
|
if ((account.memberTenantId ?? "") !== tenantId) patch.memberTenantId = tenantId || null;
|
||||||
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
|
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
|
||||||
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
|
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
|
||||||
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
||||||
@@ -245,6 +247,18 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
{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.")}
|
{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>
|
</p>
|
||||||
|
|
||||||
|
{ctx.tenants && (ctx.tenants.length > 0 || tenantId) && (
|
||||||
|
<>
|
||||||
|
<h3>{t("Tenant")}</h3>
|
||||||
|
<select className="input admin-wide" aria-label={t("Tenant")} value={tenantId} disabled={!editable || self} onChange={(e) => setTenantId(e.target.value)}>
|
||||||
|
<option value="">{t("No tenant")}</option>
|
||||||
|
{ctx.tenants.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
|
||||||
|
{tenantId && !ctx.tenants.some((x) => x.id === tenantId) && <option value={tenantId}>{tenantId}</option>}
|
||||||
|
</select>
|
||||||
|
<p className="hint">{self ? t("You can't move your own account into a tenant.") : t("An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3>{t("Storage")}</h3>
|
<h3>{t("Storage")}</h3>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<p className="hint" style={{ marginTop: 0 }}>
|
<p className="hint" style={{ marginTop: 0 }}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Avatar, Empty, Spinner } from "@/ui/misc";
|
|||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
|
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
|
||||||
import { AccountSheet } from "./AccountSheet";
|
import { AccountSheet } from "./AccountSheet";
|
||||||
|
import { listTenantNames } from "@/lib/adminTenants";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
|||||||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||||
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
|
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
|
||||||
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
|
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
|
||||||
|
const [tenants, setTenants] = useState<Array<{ id: string; name: string }> | null>(null);
|
||||||
|
|
||||||
// Typing is not a query per keystroke.
|
// Typing is not a query per keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,6 +76,7 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
|||||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
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));
|
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()));
|
void listGroups().then((list) => setGroups(new Map(list.map((g) => [g.id, g]))), () => setGroups(new Map()));
|
||||||
|
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) void listTenantNames().then(setTenants, () => setTenants(null));
|
||||||
}, [perms, reload]);
|
}, [perms, reload]);
|
||||||
|
|
||||||
// An account opened by address that is not on the page being shown.
|
// An account opened by address that is not on the page being shown.
|
||||||
@@ -103,9 +106,10 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
|
|||||||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||||||
roles,
|
roles,
|
||||||
groups,
|
groups,
|
||||||
|
tenants,
|
||||||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||||||
};
|
};
|
||||||
}, [page, serverDomains, roles, groups, session]);
|
}, [page, serverDomains, roles, groups, tenants, session]);
|
||||||
|
|
||||||
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
|
const selected = selectedId && selectedId !== "new" ? (page?.accounts.find((a) => a.id === selectedId) ?? loose) : null;
|
||||||
const close = () => navigate("/admin/accounts");
|
const close = () => navigate("/admin/accounts");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Globe, LayoutDashboard, List, ShieldCheck, User, UsersRound } from "lucide-react";
|
import { Building2, Globe, LayoutDashboard, List, ShieldCheck, User, UsersRound } from "lucide-react";
|
||||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
@@ -10,6 +10,7 @@ export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string
|
|||||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||||
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
|
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
|
||||||
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
|
lists: { group: "Directory", label: "Mailing lists", icon: <List size={20} /> },
|
||||||
|
tenants: { group: "Access", label: "Tenants", icon: <Building2 size={20} /> },
|
||||||
roles: { group: "Access", label: "Roles", icon: <ShieldCheck size={20} /> },
|
roles: { group: "Access", label: "Roles", icon: <ShieldCheck size={20} /> },
|
||||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DomainsAdmin } from "./DomainsAdmin";
|
|||||||
import { GroupsAdmin } from "./GroupsAdmin";
|
import { GroupsAdmin } from "./GroupsAdmin";
|
||||||
import { ListsAdmin } from "./ListsAdmin";
|
import { ListsAdmin } from "./ListsAdmin";
|
||||||
import { RolesAdmin } from "./RolesAdmin";
|
import { RolesAdmin } from "./RolesAdmin";
|
||||||
|
import { TenantsAdmin } from "./TenantsAdmin";
|
||||||
import { currentAdminSection } from "./AdminNav";
|
import { currentAdminSection } from "./AdminNav";
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
|||||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||||
groups: (id) => <GroupsAdmin selectedId={id} />,
|
groups: (id) => <GroupsAdmin selectedId={id} />,
|
||||||
lists: (id) => <ListsAdmin selectedId={id} />,
|
lists: (id) => <ListsAdmin selectedId={id} />,
|
||||||
|
tenants: (id) => <TenantsAdmin selectedId={id} />,
|
||||||
roles: (id) => <RolesAdmin selectedId={id} />,
|
roles: (id) => <RolesAdmin selectedId={id} />,
|
||||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Globe, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { can, canGrantRole, type RoleDef } from "@/lib/adminAccess";
|
||||||
|
import { describeDirectoryError } from "@/lib/adminDirectory";
|
||||||
|
import { describeLinked, DomainError } from "@/lib/adminDomains";
|
||||||
|
import {
|
||||||
|
countTenantMembers,
|
||||||
|
createTenant,
|
||||||
|
destroyTenant,
|
||||||
|
drawableLogo,
|
||||||
|
quotasPatch,
|
||||||
|
setDomainTenant,
|
||||||
|
tenantDomains,
|
||||||
|
updateTenant,
|
||||||
|
TENANT_MEMBERS,
|
||||||
|
TENANT_QUOTAS,
|
||||||
|
type DirectoryTenant,
|
||||||
|
type TenantMemberKind,
|
||||||
|
type TenantQuota,
|
||||||
|
type TenantRoles,
|
||||||
|
} from "@/lib/adminTenants";
|
||||||
|
import { formatSize } from "@/lib/format";
|
||||||
|
import { proxiedImageUrl } from "@/lib/html";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import { Dialog } from "@/ui/dialog";
|
||||||
|
import { Spinner } from "@/ui/misc";
|
||||||
|
import { toast } from "@/ui/toast";
|
||||||
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
const GIB = 1024 ** 3;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Null to create one. */
|
||||||
|
tenant: DirectoryTenant | null;
|
||||||
|
roles: ReadonlyMap<string, RoleDef> | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label for each quota, and for each kind of thing a tenant holds. */
|
||||||
|
function quotaLabel(q: TenantQuota): string {
|
||||||
|
switch (q) {
|
||||||
|
case "maxAccounts": return t("Accounts");
|
||||||
|
case "maxGroups": return t("Groups");
|
||||||
|
case "maxMailingLists": return t("Mailing lists");
|
||||||
|
case "maxDomains": return t("Domains");
|
||||||
|
case "maxRoles": return t("Roles");
|
||||||
|
case "maxDkimKeys": return t("DKIM keys");
|
||||||
|
case "maxDiskQuota": return t("Storage in GB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleKey = (roles: TenantRoles | undefined) => (!roles || roles["@type"] === "Default" ? "Default" : `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`);
|
||||||
|
const rolesFromKey = (key: string): TenantRoles =>
|
||||||
|
key.startsWith("custom:") ? { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) } : { "@type": "Default" };
|
||||||
|
|
||||||
|
/** A quota as the field shows it: GB for disk space, a whole number for the rest, empty for no limit. */
|
||||||
|
const fieldOf = (q: TenantQuota, v: number | undefined) => (v == null ? "" : q === "maxDiskQuota" ? String(Math.round((v / GIB) * 10) / 10) : String(v));
|
||||||
|
const valueOf = (q: TenantQuota, s: string): number | null => {
|
||||||
|
const n = Number(s.replace(",", "."));
|
||||||
|
if (!s.trim() || !Number.isFinite(n) || n < 0) return null;
|
||||||
|
return q === "maxDiskQuota" ? Math.round(n * GIB) : Math.floor(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tenant, opened beside the list.
|
||||||
|
*
|
||||||
|
* Name, logo, role and quotas save together. What is in the tenant is shown
|
||||||
|
* rather than stored on it: counts of each kind, read with a `memberTenantId`
|
||||||
|
* filter, and its domains, which are added and taken out on the spot because
|
||||||
|
* each is a change to the domain.
|
||||||
|
*/
|
||||||
|
export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||||
|
const perms = usePermissions();
|
||||||
|
const creating = tenant === null;
|
||||||
|
const editable = creating ? can(perms, "Tenant", "Create") : can(perms, "Tenant", "Update");
|
||||||
|
|
||||||
|
const [name, setName] = useState(tenant?.name ?? "");
|
||||||
|
const [logo, setLogo] = useState(tenant?.logo ?? "");
|
||||||
|
const [role, setRole] = useState(roleKey(tenant?.roles));
|
||||||
|
const [quotas, setQuotas] = useState<Record<TenantQuota, string>>(() => Object.fromEntries(TENANT_QUOTAS.map((q) => [q, fieldOf(q, tenant?.quotas?.[q])])) as Record<TenantQuota, string>);
|
||||||
|
const [counts, setCounts] = useState<Partial<Record<TenantMemberKind, number>> | null>(null);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tenant) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void countTenantMembers(tenant.id).then((c) => !cancelled && setCounts(c));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [tenant, revision]);
|
||||||
|
|
||||||
|
const roleOptions = useMemo(() => {
|
||||||
|
const options = [{ value: "Default", label: t("Default tenant roles") }];
|
||||||
|
for (const r of roles?.values() ?? []) {
|
||||||
|
if (canGrantRole(perms, r.id, roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
|
||||||
|
}
|
||||||
|
if (!options.some((o) => o.value === role)) options.push({ value: role, label: t("Custom role") });
|
||||||
|
return options;
|
||||||
|
}, [perms, roles, role]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const values = Object.fromEntries(TENANT_QUOTAS.map((q) => [q, valueOf(q, quotas[q])])) as Record<TenantQuota, number | null>;
|
||||||
|
if (!tenant) {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError(t("A tenant needs a name."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const set = Object.fromEntries(Object.entries(values).filter(([, v]) => v != null)) as Record<string, number>;
|
||||||
|
const id = await createTenant({ name, logo: logo.trim() || null, roles: rolesFromKey(role), quotas: set });
|
||||||
|
toast.success(t("Created {name}", { name: name.trim() }));
|
||||||
|
onCreated(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch: Record<string, unknown> = { ...quotasPatch(tenant.quotas, values) };
|
||||||
|
if (tenant.name !== name.trim()) patch.name = name.trim();
|
||||||
|
if ((tenant.logo ?? "") !== logo.trim()) patch.logo = logo.trim() || null;
|
||||||
|
if (roleKey(tenant.roles) !== role) patch.roles = rolesFromKey(role);
|
||||||
|
if (!Object.keys(patch).length) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateTenant(tenant.id, patch);
|
||||||
|
toast.success(t("Saved {name}", { name: name.trim() }));
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(describeDirectoryError(err, "tenant"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawable = drawableLogo(logo.trim());
|
||||||
|
const logoSrc = drawable?.startsWith("data:") ? drawable : drawable ? proxiedImageUrl(drawable) : null;
|
||||||
|
const held = counts ? Object.values(counts).reduce((a, b) => a + (b ?? 0), 0) : null;
|
||||||
|
const countsComplete = counts !== null && TENANT_MEMBERS.every((m) => typeof counts[m.key] === "number");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="admin-sheet" aria-label={creating ? t("New tenant") : tenant.name}>
|
||||||
|
<div className="admin-sheet-head">
|
||||||
|
{logoSrc ? <img className="admin-tenant-logo" src={logoSrc} alt="" /> : null}
|
||||||
|
<div className="grow">
|
||||||
|
<h2 className="truncate">{creating ? t("New tenant") : tenant.name}</h2>
|
||||||
|
{tenant && <div className="hint">{t("{used} used", { used: formatSize(tenant.usedDiskQuota ?? 0) })}</div>}
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-sheet-body">
|
||||||
|
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view tenants but not change them.")}</p>}
|
||||||
|
|
||||||
|
<h3>{t("Profile")}</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="admin-tenant-name">{t("Name")}</label>
|
||||||
|
<input id="admin-tenant-name" className="input" value={name} disabled={!editable} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
|
||||||
|
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
|
||||||
|
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!creating && (
|
||||||
|
<>
|
||||||
|
<h3>{t("What it holds")}</h3>
|
||||||
|
{counts === null ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<dl className="admin-kv">
|
||||||
|
{TENANT_MEMBERS.map((m) => {
|
||||||
|
const limit = tenant.quotas?.[m.quota];
|
||||||
|
const n = counts[m.key];
|
||||||
|
return (
|
||||||
|
<div key={m.key} style={{ display: "contents" }}>
|
||||||
|
<dt>{quotaLabel(m.quota)}</dt>
|
||||||
|
<dd>{n == null ? "—" : limit != null ? t("{n} of {limit}", { n, limit }) : n}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<dt>{t("Storage")}</dt>
|
||||||
|
<dd>{tenant.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(tenant.usedDiskQuota ?? 0), total: formatSize(tenant.quotas.maxDiskQuota) }) : formatSize(tenant.usedDiskQuota ?? 0)}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>{t("Domains")}</h3>
|
||||||
|
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>{t("Limits")}</h3>
|
||||||
|
<div className="admin-quota-grid">
|
||||||
|
{TENANT_QUOTAS.map((q) => (
|
||||||
|
<div key={q} className="field">
|
||||||
|
<label htmlFor={`admin-tenant-${q}`}>{quotaLabel(q)}</label>
|
||||||
|
<input id={`admin-tenant-${q}`} className="input" inputMode="decimal" value={quotas[q]} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuotas({ ...quotas, [q]: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
|
||||||
|
|
||||||
|
<h3>{t("Role")}</h3>
|
||||||
|
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="hint">{t("The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.")}</p>
|
||||||
|
|
||||||
|
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
{!creating && can(perms, "Tenant", "Destroy") && (
|
||||||
|
<DeleteTenant
|
||||||
|
tenant={tenant}
|
||||||
|
blocked={
|
||||||
|
!countsComplete
|
||||||
|
? t("Checking what is still in this tenant…")
|
||||||
|
: held
|
||||||
|
? t("It still holds accounts, domains or other things. Move them out first.")
|
||||||
|
: 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 || !name.trim()} onClick={() => void save()}>
|
||||||
|
{creating ? t("Create tenant") : t("Save changes")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTenant; canChange: boolean; onChanged: () => void }) {
|
||||||
|
const [state, setState] = useState<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> } | null>(null);
|
||||||
|
const [pick, setPick] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
tenantDomains(tenant.id).then(
|
||||||
|
(s) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState(s);
|
||||||
|
setPick(s.unassigned[0]?.id ?? "");
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ inTenant: [], unassigned: [] });
|
||||||
|
setError(describeDirectoryError(err, "domain"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [tenant, revision]);
|
||||||
|
|
||||||
|
const move = async (domain: { id: string; name: string }, into: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setDomainTenant(domain.id, into ? tenant.id : null);
|
||||||
|
toast.success(into ? t("Added {domain} to {tenant}", { domain: domain.name, tenant: tenant.name }) : t("Took {domain} out of {tenant}", { domain: domain.name, tenant: tenant.name }));
|
||||||
|
setRevision((n) => n + 1);
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(describeDirectoryError(err, "domain"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) return <Spinner />;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{state.inTenant.length ? (
|
||||||
|
<ul className="admin-members">
|
||||||
|
{state.inTenant.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<Globe size={16} aria-hidden="true" />
|
||||||
|
<span className="grow truncate notranslate" translate="no">{d.name}</span>
|
||||||
|
{canChange && (
|
||||||
|
<button className="icon-btn sm" aria-label={t("Take {domain} out of the tenant", { domain: d.name })} disabled={busy} onClick={() => void move(d, false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="hint" style={{ marginTop: 0 }}>{t("No domains in this tenant yet")}</p>
|
||||||
|
)}
|
||||||
|
{canChange && state.unassigned.length > 0 && (
|
||||||
|
<div className="row mt-8">
|
||||||
|
<select className="input grow" aria-label={t("Domain to add")} value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||||
|
{state.unassigned.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-sm" disabled={busy || !pick} onClick={() => { const d = state.unassigned.find((x) => x.id === pick); if (d) void move(d, true); }}>
|
||||||
|
<Plus size={14} /> {t("Add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||||
|
<p className="hint">{t("Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteTenant({ tenant, blocked, onDeleted }: { tenant: DirectoryTenant; 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);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{t("Delete")}</h3>
|
||||||
|
<div className="admin-danger">
|
||||||
|
<p>{blocked ?? t("An empty tenant can be deleted.")}</p>
|
||||||
|
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||||
|
<Trash2 size={14} /> {t("Delete tenant…")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={t("Delete {name}?", { name: tenant.name })}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={busy || typed.trim() !== tenant.name}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await destroyTenant(tenant.id);
|
||||||
|
toast.success(t("Deleted {name}", { name: tenant.name }));
|
||||||
|
setOpen(false);
|
||||||
|
onDeleted();
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
|
||||||
|
? t("Still holds {things}. Move them out first.", { things: describeLinked(err.linked) })
|
||||||
|
: describeDirectoryError(err, "tenant"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Delete tenant")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p style={{ marginTop: 0 }}>{t("It can't be undone.")}</p>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="admin-tenant-delete-confirm">{t("Type {name} to confirm", { name: tenant.name })}</label>
|
||||||
|
<input id="admin-tenant-delete-confirm" className="input" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { Building2, ChevronLeft, ChevronRight, Plus, Search } from "lucide-react";
|
||||||
|
import { can, type RoleDef } from "@/lib/adminAccess";
|
||||||
|
import { describeDirectoryError, listRoles } from "@/lib/adminDirectory";
|
||||||
|
import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/lib/adminTenants";
|
||||||
|
import { formatSize } from "@/lib/format";
|
||||||
|
import { proxiedImageUrl } from "@/lib/html";
|
||||||
|
import { plural, t } from "@/lib/i18n";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import { Empty, Spinner } from "@/ui/misc";
|
||||||
|
import { usePermissions } from "./usePermissions";
|
||||||
|
import { TenantSheet } from "./TenantSheet";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenants: separate organisations on one server, each with its own people,
|
||||||
|
* domains and limits.
|
||||||
|
*
|
||||||
|
* Shown to whoever may read them, whatever the edition says -- the edition is a
|
||||||
|
* licence claim, not an authority -- but on a server that does not report
|
||||||
|
* Enterprise the page says what that means for the people inside one.
|
||||||
|
*/
|
||||||
|
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const perms = usePermissions();
|
||||||
|
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [page, setPage] = useState<{ tenants: DirectoryTenant[]; total: number } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||||
|
const [loose, setLoose] = useState<DirectoryTenant | null>(null);
|
||||||
|
|
||||||
|
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 queryTenants({ text: query, position, limit: PAGE_SIZE });
|
||||||
|
const tenants = await getTenants(q.ids);
|
||||||
|
if (!cancelled) setPage({ tenants, total: q.total });
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPage({ tenants: [], total: 0 });
|
||||||
|
setError(describeDirectoryError(err, "tenant"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [query, position, reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||||||
|
}, [perms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId || selectedId === "new" || page?.tenants.some((x) => x.id === selectedId)) {
|
||||||
|
setLoose(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void getTenants([selectedId]).then(
|
||||||
|
([x]) => { if (!cancelled) setLoose(x ?? null); },
|
||||||
|
() => { if (!cancelled) setLoose(null); },
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedId, page]);
|
||||||
|
|
||||||
|
const selected = selectedId && selectedId !== "new" ? (page?.tenants.find((x) => x.id === selectedId) ?? loose) : null;
|
||||||
|
const close = () => navigate("/admin/tenants");
|
||||||
|
const changed = () => setReload((n) => n + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="admin-head">
|
||||||
|
<div className="grow">
|
||||||
|
<h1>{t("Tenants")}</h1>
|
||||||
|
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
|
||||||
|
</div>
|
||||||
|
{can(perms, "Tenant", "Create") && (
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate("/admin/tenants/new")}>
|
||||||
|
<Plus size={16} /> {t("New tenant")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edition !== "enterprise" && (
|
||||||
|
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 tenants")} aria-label={t("Search tenants")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
{page === null ? (
|
||||||
|
<Spinner />
|
||||||
|
) : page.tenants.length === 0 ? (
|
||||||
|
!error && <Empty icon={<Building2 size={32} />} title={query ? t("No tenants match") : t("No tenants yet")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("Tenant")}</th>
|
||||||
|
<th>{t("Storage")}</th>
|
||||||
|
<th className="hide-mobile">{t("Account limit")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{page.tenants.map((x) => {
|
||||||
|
const logo = drawableLogo(x.logo);
|
||||||
|
const src = logo?.startsWith("data:") ? logo : logo ? proxiedImageUrl(logo) : null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={x.id}
|
||||||
|
className={x.id === selectedId ? "selected" : ""}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate(`/admin/tenants/${x.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/admin/tenants/${x.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={t("Open {name}", { name: x.name })}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="admin-who">
|
||||||
|
{src ? <img className="admin-tenant-logo sm" src={src} alt="" /> : <Building2 size={20} className="muted" aria-hidden="true" />}
|
||||||
|
<div className="admin-who-name truncate">{x.name}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="muted">
|
||||||
|
{x.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(x.usedDiskQuota ?? 0), total: formatSize(x.quotas.maxDiskQuota) }) : t("{used} · no limit", { used: formatSize(x.usedDiskQuota ?? 0) })}
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile muted" style={{ fontVariantNumeric: "tabular-nums" }}>{x.quotas?.maxAccounts ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{page.total <= PAGE_SIZE && position === 0 ? (
|
||||||
|
<p className="hint admin-count">{plural(page.total, { one: "{n} tenant", other: "{n} tenants" })}</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-pager">
|
||||||
|
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.tenants.length, total: page.total })}</span>
|
||||||
|
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
|
||||||
|
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.tenants.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selectedId === "new" || selected) && (
|
||||||
|
<TenantSheet
|
||||||
|
key={selectedId}
|
||||||
|
tenant={selectedId === "new" ? null : selected!}
|
||||||
|
roles={roles}
|
||||||
|
onClose={close}
|
||||||
|
onChanged={changed}
|
||||||
|
onCreated={(id) => {
|
||||||
|
changed();
|
||||||
|
navigate(`/admin/tenants/${id}`);
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
changed();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
import type { DirectoryTenant } from "@/lib/adminTenants";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
counts: { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 } as Record<string, number>,
|
||||||
|
updateTenant: vi.fn(async () => {}),
|
||||||
|
setDomainTenant: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/adminTenants", async (original) => ({
|
||||||
|
...(await original<typeof import("@/lib/adminTenants")>()),
|
||||||
|
countTenantMembers: vi.fn(async () => api.counts),
|
||||||
|
tenantDomains: vi.fn(async () => ({ inTenant: [{ id: "d3", name: "old-brand.example" }], unassigned: [{ id: "d4", name: "spare.example" }] })),
|
||||||
|
updateTenant: api.updateTenant,
|
||||||
|
setDomainTenant: api.setDomainTenant,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { TenantSheet } = await import("../TenantSheet");
|
||||||
|
|
||||||
|
const tenant: DirectoryTenant = { id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, quotas: { maxAccounts: 25, maxDomains: 2, maxOauthClients: 3 }, usedDiskQuota: 0 };
|
||||||
|
const ALL = ["sysTenantGet", "sysTenantQuery", "sysTenantUpdate", "sysTenantDestroy", "sysDomainUpdate"];
|
||||||
|
const signIn = (permissions: string[]) =>
|
||||||
|
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||||
|
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.trim() === label || b.textContent?.includes(label));
|
||||||
|
const type = async (el: HTMLInputElement, value: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("the tenant sheet", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const render = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
api.updateTenant.mockClear();
|
||||||
|
api.setDomainTenant.mockClear();
|
||||||
|
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 };
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows what it holds against its limits, and will not delete while it holds anything", async () => {
|
||||||
|
signIn(ALL);
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector(".admin-kv")?.textContent).toContain("1 of 25");
|
||||||
|
expect(button(host, "Delete tenant…")?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the delete once it is empty", async () => {
|
||||||
|
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0 };
|
||||||
|
signIn(ALL);
|
||||||
|
await render();
|
||||||
|
expect(button(host, "Delete tenant…")?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a changed limit as one pointer, and an emptied one as no limit", async () => {
|
||||||
|
signIn(ALL);
|
||||||
|
await render();
|
||||||
|
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxAccounts")!, "30");
|
||||||
|
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxDomains")!, "");
|
||||||
|
await act(async () => button(host, "Save changes")!.click());
|
||||||
|
expect(api.updateTenant).toHaveBeenCalledWith("t1", { "quotas/maxAccounts": 30, "quotas/maxDomains": null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a domain in, and offers no domain moves without the permission to change domains", async () => {
|
||||||
|
signIn(ALL);
|
||||||
|
await render();
|
||||||
|
await act(async () => button(host, "Add")!.click());
|
||||||
|
expect(api.setDomainTenant).toHaveBeenCalledWith("d4", "t1");
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
root = createRoot(host);
|
||||||
|
signIn(["sysTenantGet", "sysTenantQuery"]);
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector('select[aria-label="Domain to add"]')).toBeNull();
|
||||||
|
expect(button(host, "Take old-brand.example out of the tenant")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ export interface DirectoryContext {
|
|||||||
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
|
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
|
||||||
roles: Map<string, RoleDef> | null;
|
roles: Map<string, RoleDef> | null;
|
||||||
groups: Map<string, DirectoryAccount>;
|
groups: Map<string, DirectoryAccount>;
|
||||||
|
/** Tenants an account can be put in; absent when the viewer cannot read them, which hides the choice. */
|
||||||
|
tenants?: Array<{ id: string; name: string }> | null;
|
||||||
/** Registry ids and addresses that are the signed-in account itself. */
|
/** Registry ids and addresses that are the signed-in account itself. */
|
||||||
self: { ids: Set<string>; address: string };
|
self: { ids: Set<string>; address: string };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user