Merge pull request #364 from Coffey-Labs/feat/admin-tenants
Add Tenants to Administration, and let an account be put in one
This commit is contained in:
+41
-4
@@ -1260,6 +1260,42 @@ 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 — or
|
||||||
|
reports no edition at all — the page is only the notice *Tenants are a Stalwart
|
||||||
|
Enterprise feature.*: no list, no search, nothing to create. On Enterprise the
|
||||||
|
notice is left out, unless `SHOW_ENTERPRISE_NOTICES=1` asks for it above the
|
||||||
|
list, as the public demo does. On Enterprise, 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, role and DKIM key names its
|
||||||
|
tenant, so the counts are queries for those. A domain created in a tenant
|
||||||
|
brings its keys with it.
|
||||||
|
- **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. A domain comes out only once none of the tenant's accounts are on it —
|
||||||
|
Stalwart would allow it, and strand them.
|
||||||
|
- **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. Stalwart puts something in a tenant only on a domain
|
||||||
|
in that tenant, so the choice is between no tenant and the domain's own, and
|
||||||
|
a new account starts in its domain's 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 +1354,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1679,6 +1714,7 @@ wizard, because either would be state.
|
|||||||
| Variable | Default | Does |
|
| Variable | Default | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
|
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
|
||||||
|
| `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off |
|
||||||
| `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link |
|
| `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link |
|
||||||
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
|
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
|
||||||
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
|
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
|
||||||
@@ -1787,7 +1823,8 @@ demo user is: `admin` (the default), `tenant-admin` (the queue but not the
|
|||||||
history), `helpdesk` — a custom role that may view and edit accounts but not
|
history), `helpdesk` — a custom role that may view and edit accounts but not
|
||||||
create or delete them, and read domains — or `user`, who is not offered the
|
create or delete them, and read domains — or `user`, who is not offered the
|
||||||
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
|
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
|
||||||
does. Two mailing lists round it out.
|
does, and `MOCK_EDITION=enterprise` reports Enterprise so Tenants can be
|
||||||
|
worked on (the default, `oss`, shows only its notice). Two mailing lists round it out.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ 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, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, `ihasmail-tenant-test.ttlhost.com`, all removed. The live run changed the design twice:
|
||||||
|
|
||||||
|
- **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/<name>` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
|
||||||
|
- **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
|
||||||
|
- **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
|
||||||
|
- **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
|
||||||
|
- **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
|
||||||
|
|
||||||
|
Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
|
||||||
|
|
||||||
- **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
|
||||||
@@ -403,13 +403,14 @@ without a real mailbox. It reproduces the things a naive fake would get wrong,
|
|||||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||||
**per-account** rather than session-level, identity signatures capped at 2047
|
**per-account** rather than session-level, identity signatures capped at 2047
|
||||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||||
RFC 8984's. Five switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
RFC 8984's. Six switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||||
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
||||||
calendar write that asks for scheduling messages, the way an account without
|
calendar write that asks for scheduling messages, the way an account without
|
||||||
that permission is refused; `MOCK_ROLE` decides who the demo user is for
|
that permission is refused; `MOCK_ROLE` decides who the demo user is for
|
||||||
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`;
|
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`;
|
||||||
and `MOCK_METRICS=off` refuses the dashboard's metric history, as Community does.
|
`MOCK_METRICS=off` refuses the dashboard's metric history, as Community does;
|
||||||
|
and `MOCK_EDITION=enterprise` reports Enterprise, which Tenants needs.
|
||||||
|
|
||||||
It tracks the current release rather than 0.16 in general, and each behaviour
|
It tracks the current release rather than 0.16 in general, and each behaviour
|
||||||
is confirmed against a real server before it is copied here — the comments say
|
is confirmed against a real server before it is copied here — the comments say
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet".
|
|||||||
|
|
||||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
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.
|
||||||
|
|||||||
+6
-1
@@ -896,7 +896,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
* session that may administer -- where the operator says its own
|
* session that may administer -- where the operator says its own
|
||||||
* administration is.
|
* administration is.
|
||||||
*/
|
*/
|
||||||
server: { edition: info.edition, adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null },
|
server: {
|
||||||
|
edition: info.edition,
|
||||||
|
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null,
|
||||||
|
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
|
||||||
|
enterpriseNotices: config.showEnterpriseNotices,
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Whether this session may administer: the installation offers it
|
* Whether this session may administer: the installation offers it
|
||||||
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||||
|
|||||||
@@ -307,6 +307,13 @@ export const config = {
|
|||||||
*/
|
*/
|
||||||
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
|
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
|
||||||
stalwartAdminUrls: stalwartServers.adminUrls,
|
stalwartAdminUrls: stalwartServers.adminUrls,
|
||||||
|
/**
|
||||||
|
* Say that an Enterprise-only section is Enterprise-only even on an
|
||||||
|
* Enterprise server. Off, as a real installation wants it; the public demo
|
||||||
|
* turns it on, because it reports Enterprise to show those sections and
|
||||||
|
* should not suggest they come without the licence.
|
||||||
|
*/
|
||||||
|
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
|
||||||
appSecret,
|
appSecret,
|
||||||
trustProxy: bool("TRUST_PROXY", true),
|
trustProxy: bool("TRUST_PROXY", true),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -255,3 +255,48 @@ 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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("something in a tenant has to be on a domain in it, and something in none may be anywhere", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const outside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "stray", domainId: "d1", memberTenantId: "t1" } } }) as { notCreated?: Record<string, { type: string; objectId: { object: string } }> };
|
||||||
|
assert.equal(outside.notCreated?.n?.type, "invalidForeignKey");
|
||||||
|
assert.equal(outside.notCreated!.n!.objectId.object, "Domain");
|
||||||
|
const inside = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "team", domainId: "d3", memberTenantId: "t1" } } }) as { created?: Record<string, { id: string }> };
|
||||||
|
assert.ok(inside.created?.n?.id);
|
||||||
|
const none = dir.handlers["x:MailingList/set"]!({ create: { n: { name: "open", domainId: "d3" } } }) as { created?: Record<string, { id: string }> };
|
||||||
|
assert.ok(none.created?.n?.id);
|
||||||
|
const [someone] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", domainId: "d1" } }) as { ids: string[] }).ids;
|
||||||
|
const move = dir.handlers["x:Account/set"]!({ update: { [someone!]: { memberTenantId: "t1" } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||||
|
assert.equal(move.notUpdated?.[someone!]?.type, "invalidForeignKey");
|
||||||
|
});
|
||||||
|
|||||||
+110
-15
@@ -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,26 @@ 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);
|
||||||
|
/**
|
||||||
|
* Something in a tenant has to be on a domain in that tenant; something in no
|
||||||
|
* tenant may be on anyone's domain. Both as the live server answered
|
||||||
|
* (2026-09-15), including the shape of the refusal.
|
||||||
|
*/
|
||||||
|
const domainTenantRefused = (o: Obj): Obj | null => {
|
||||||
|
const tenant = o.memberTenantId ?? null;
|
||||||
|
const domain = domains.find((d) => d.id === o.domainId);
|
||||||
|
if (!tenant || !domain || (domain.memberTenantId ?? null) === tenant) return null;
|
||||||
|
return { type: "invalidForeignKey", objectId: { object: "Domain", id: domain.id } };
|
||||||
|
};
|
||||||
|
/** 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 +348,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 +365,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) ?? domainTenantRefused(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 +382,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")) {
|
||||||
@@ -370,6 +400,7 @@ export function createDirectory(opts: Options) {
|
|||||||
if (target["@type"] === "Group") failure = setError("invalidProperties", "Groups cannot be members of other groups.", ["memberGroupIds"]);
|
if (target["@type"] === "Group") failure = setError("invalidProperties", "Groups cannot be members of other groups.", ["memberGroupIds"]);
|
||||||
else if (Object.keys((next.memberGroupIds as Obj) ?? {}).some((g) => accounts.find((x) => x.id === g)?.["@type"] !== "Group")) failure = setError("invalidForeignKey", "Group does not exist.", ["memberGroupIds"]);
|
else if (Object.keys((next.memberGroupIds as Obj) ?? {}).some((g) => accounts.find((x) => x.id === g)?.["@type"] !== "Group")) failure = setError("invalidForeignKey", "Group does not exist.", ["memberGroupIds"]);
|
||||||
}
|
}
|
||||||
|
if (!failure && "memberTenantId" in patch) failure = domainTenantRefused(next);
|
||||||
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
||||||
const refused = grantRefused(next.roles);
|
const refused = grantRefused(next.roles);
|
||||||
if (refused) failure = setError("forbidden", refused);
|
if (refused) failure = setError("forbidden", refused);
|
||||||
@@ -404,7 +435,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 +462,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.
|
||||||
@@ -455,7 +489,8 @@ 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:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
|
"x:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
|
||||||
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) => f.domainId === undefined || o.domainId === f.domainId),
|
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) =>
|
||||||
|
(f.domainId === undefined || o.domainId === f.domainId) && (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId)),
|
||||||
"x:DkimSignature/set": (a) => {
|
"x:DkimSignature/set": (a) => {
|
||||||
const destroyed: string[] = [];
|
const destroyed: string[] = [];
|
||||||
for (const id of (a.destroy as string[]) ?? []) {
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
@@ -486,7 +521,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 = {};
|
||||||
@@ -504,10 +539,10 @@ export function createDirectory(opts: Options) {
|
|||||||
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
demand("sysMailingListCreate");
|
demand("sysMailingListCreate");
|
||||||
const o: Obj = { recipients: {}, aliases: {}, description: null, ...(raw as Obj) };
|
const o: Obj = { recipients: {}, aliases: {}, description: null, ...(raw as Obj) };
|
||||||
const failure = check(o);
|
const failure = check(o) ?? (o.memberTenantId ? (tenantRefused(o) ?? domainTenantRefused(o)) : null);
|
||||||
if (failure) { notCreated[cid] = failure; continue; }
|
if (failure) { notCreated[cid] = failure; continue; }
|
||||||
const id = `l${counter++}`;
|
const id = `l${counter++}`;
|
||||||
lists.push({ ...o, id, memberTenantId: null });
|
lists.push({ memberTenantId: null, ...o, id });
|
||||||
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||||
}
|
}
|
||||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
@@ -601,8 +636,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 +721,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const SHARED_CAPS: Obj = {
|
|||||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||||
|
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
|
||||||
|
const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
|
||||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||||
/**
|
/**
|
||||||
* Credential state, mutable so the self-service flows can be exercised against
|
* Credential state, mutable so the self-service flows can be exercised against
|
||||||
@@ -1430,7 +1432,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
// The account info endpoint; the only place a server reports its edition.
|
// The account info endpoint; the only place a server reports its edition.
|
||||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
|
return res.end(JSON.stringify({ permissions: directory.permissions, edition: MOCK_EDITION, locale: MOCK_LOCALE }));
|
||||||
}
|
}
|
||||||
// The registry schema, cut down to the permission list the Roles picker
|
// The registry schema, cut down to the permission list the Roles picker
|
||||||
// reads. Gzipped as the real file is, from the 0.16.22 snapshot the
|
// reads. Gzipped as the real file is, from the 0.16.22 snapshot the
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface JmapSession {
|
|||||||
edition?: string | null;
|
edition?: string | null;
|
||||||
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
|
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
|
||||||
adminUrl?: string | null;
|
adminUrl?: string | null;
|
||||||
|
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
|
||||||
|
enterpriseNotices?: boolean;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* False when this session may not administer: the operator turned it off,
|
* False when this session may not administer: the operator turned it off,
|
||||||
|
|||||||
@@ -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, dkimKeys: 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;
|
||||||
}
|
}
|
||||||
@@ -61,11 +63,13 @@ export interface DirectoryAccount {
|
|||||||
export interface DirectoryDomain {
|
export interface DirectoryDomain {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** The tenant the domain is in: an account can be in a tenant only on one of its domains. */
|
||||||
|
memberTenantId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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. */
|
||||||
@@ -119,7 +123,7 @@ async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<
|
|||||||
return res.list;
|
return res.list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
|
export const listDomains = () => all<DirectoryDomain>("Domain", ["name", "memberTenantId"]);
|
||||||
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
||||||
|
|
||||||
export async function listGroups(): Promise<DirectoryAccount[]> {
|
export async function listGroups(): Promise<DirectoryAccount[]> {
|
||||||
@@ -143,6 +147,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 +165,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 +236,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 +289,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 +303,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,188 @@
|
|||||||
|
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" },
|
||||||
|
// A domain's keys join the tenant it was created in, and keep it there.
|
||||||
|
{ key: "dkimKeys", method: "x:DkimSignature/query", filter: {}, quota: "maxDkimKeys" },
|
||||||
|
] 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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of a tenant's accounts and groups are on a domain.
|
||||||
|
*
|
||||||
|
* Stalwart lets a domain leave a tenant while the tenant still has accounts on
|
||||||
|
* it (live, 2026-09-15), leaving them in a tenant on a domain outside it --
|
||||||
|
* which it refuses to create. The panel asks this before it offers the move.
|
||||||
|
*/
|
||||||
|
export async function tenantAccountsOnDomain(tenantId: string, domainId: string): Promise<number> {
|
||||||
|
const res = await client.call<{ total?: number }>("x:Account/query", { filter: { domainId, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
|
||||||
|
return res.total ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Ein Konto kann in dem Mandanten sein, in dem seine Domain ist. In einem Mandanten ist es 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Nur Domains ohne Mandanten können hinzugefügt werden, und die Konten darauf bleiben, wo sie sind. Eine Domain kann erst entfernt werden, wenn keines der Konten dieses Mandanten mehr darauf ist.",
|
||||||
|
"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.": "Mandanten sind eine Funktion von Stalwart Enterprise.",
|
||||||
|
"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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} Konto dieses Mandanten ist noch auf {domain}. Verschieben oder löschen Sie es, bevor Sie die Domain entfernen.", other: "{n} Konten dieses Mandanten sind noch auf {domain}. Verschieben oder löschen Sie sie, bevor Sie die Domain entfernen." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Una cuenta puede estar en el inquilino en el que está su dominio. En 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Solo se pueden añadir dominios que no estén en ningún inquilino, y las cuentas que ya están en uno se quedan donde están. Un dominio solo se puede quitar cuando ninguna cuenta de este inquilino esté en él.",
|
||||||
|
"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.": "Los inquilinos son una función de Stalwart Enterprise.",
|
||||||
|
"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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} cuenta de este inquilino sigue en {domain}. Muévala o elimínela antes de quitar el dominio.", other: "{n} cuentas de este inquilino siguen en {domain}. Muévalas o elimínelas antes de quitar el dominio." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Un compte peut être dans le locataire où se trouve son domaine. Dans un locataire, il 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Seuls les domaines hors de tout locataire peuvent être ajoutés, et les comptes déjà dessus restent où ils sont. Un domaine ne peut être retiré qu’une fois qu’aucun compte de ce locataire n’y est plus.",
|
||||||
|
"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.": "Les locataires sont une fonctionnalité de Stalwart Enterprise.",
|
||||||
|
"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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} compte de ce locataire est encore sur {domain}. Déplacez-le ou supprimez-le avant de retirer le domaine.", other: "{n} comptes de ce locataire sont encore sur {domain}. Déplacez-les ou supprimez-les avant de retirer le domaine." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "追加できるのは、どのテナントにも属していないドメインだけで、既存のアカウントはそのまま残ります。ドメインを外せるのは、このテナントのアカウントがそのドメインに 1 つも残っていないときだけです。",
|
||||||
|
"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.": "テナントは Stalwart 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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { other: "このテナントのアカウントがまだ {n} 件 {domain} にあります。ドメインを外す前に、移動するか削除してください。" },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Een account kan in de tenant zitten waarin zijn domein zit. In een tenant wordt het beperkt door de rol van de tenant en telt het 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Alleen domeinen die in geen enkele tenant zitten kunnen worden toegevoegd, en de accounts die er al op staan blijven waar ze zijn. Een domein kan er pas uit als geen enkel account van deze tenant er nog op staat.",
|
||||||
|
"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.": "Tenants zijn een functie van Stalwart Enterprise.",
|
||||||
|
"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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder het voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Uma conta pode estar no locatário em que está o seu domínio. Num locatário, ela é 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Só podem ser adicionados domínios que não estejam em nenhum locatário, e as contas já existentes num deles ficam onde estão. Um domínio só pode ser retirado quando nenhuma conta deste locatário estiver nele.",
|
||||||
|
"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.": "Locatários são um recurso do Stalwart Enterprise.",
|
||||||
|
"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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} conta deste locatário ainda está em {domain}. Mova-a ou exclua-a antes de retirar o domínio.", other: "{n} contas deste locatário ainda estão em {domain}. Mova-as ou exclua-as antes de retirar o domínio." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Добавить можно только домены, не принадлежащие ни одному арендатору, а учётные записи на них остаются на месте. Домен можно убрать, только когда на нём не осталось учётных записей этого арендатора.",
|
||||||
|
"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.": "Арендаторы — функция Stalwart 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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} учётная запись этого арендатора ещё на {domain}. Перенесите или удалите её, прежде чем убирать домен.", few: "{n} учётные записи этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен.", many: "{n} учётных записей этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен.", other: "{n} учётной записи этого арендатора ещё на {domain}. Перенесите или удалите их, прежде чем убирать домен." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "Додати можна лише домени, що не належать жодному орендарю, а облікові записи на них лишаються на місці. Домен можна прибрати, лише коли на ньому не лишилося облікових записів цього орендаря.",
|
||||||
|
"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.": "Орендарі — функція Stalwart 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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} обліковий запис цього орендаря ще на {domain}. Перенесіть або видаліть його, перш ніж прибирати домен.", few: "{n} облікові записи цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен.", many: "{n} облікових записів цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен.", other: "{n} облікового запису цього орендаря ще на {domain}. Перенесіть або видаліть їх, перш ніж прибирати домен." },
|
||||||
"{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 can be in the tenant its domain is in. In a tenant it 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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.": "只能添加不属于任何租户的域名,其上已有的账户保持不变。只有当此租户在该域名上没有任何账户时,才能将域名移出。",
|
||||||
|
"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.": "租户是 Stalwart 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,8 @@ 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} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { other: "此租户仍有 {n} 个账户位于 {domain}。移出该域名前,请先移动或删除这些账户。" },
|
||||||
"{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);
|
||||||
|
|
||||||
@@ -92,6 +93,11 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
|
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
|
||||||
}, [ctx.domains, domainId]);
|
}, [ctx.domains, domainId]);
|
||||||
|
|
||||||
|
// A new account starts in the tenant of the domain it is being made on.
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating) setTenantId(ctx.domains.find((d) => d.id === domainId)?.memberTenantId ?? "");
|
||||||
|
}, [creating, domainId, ctx.domains]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||||
@@ -101,6 +107,13 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
|
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
|
||||||
|
/*
|
||||||
|
* Stalwart refuses an account in a tenant on a domain outside it (live,
|
||||||
|
* 2026-09-15: invalidForeignKey naming the domain), and allows one in no
|
||||||
|
* tenant on a tenant's domain. So the only tenant to offer is the domain's.
|
||||||
|
*/
|
||||||
|
const domainTenant = ctx.domains.find((d) => d.id === (account?.domainId ?? domainId))?.memberTenantId ?? null;
|
||||||
|
const tenantName = (id: string) => ctx.tenants?.find((x) => x.id === id)?.name ?? id;
|
||||||
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
|
const address = account?.emailAddress ?? `${name}@${domainName(domainId)}`;
|
||||||
|
|
||||||
const roleOptions = useMemo(() => {
|
const roleOptions = useMemo(() => {
|
||||||
@@ -132,7 +145,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 +153,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 +259,22 @@ 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 && (domainTenant || 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>
|
||||||
|
{domainTenant && <option value={domainTenant}>{tenantName(domainTenant)}</option>}
|
||||||
|
{tenantId && tenantId !== domainTenant && <option value={tenantId}>{tenantName(tenantId)}</option>}
|
||||||
|
</select>
|
||||||
|
<p className="hint">
|
||||||
|
{self
|
||||||
|
? t("You can't move your own account into a tenant.")
|
||||||
|
: t("An account can be in the tenant its domain is in. In a tenant it 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,400 @@
|
|||||||
|
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,
|
||||||
|
tenantAccountsOnDomain,
|
||||||
|
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 { plural, 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 {
|
||||||
|
if (!into) {
|
||||||
|
const stranded = await tenantAccountsOnDomain(tenant.id, domain.id);
|
||||||
|
if (stranded > 0) {
|
||||||
|
setError(plural(stranded, {
|
||||||
|
one: "{n} account in this tenant is still on {domain}. Move it or delete it before taking the domain out.",
|
||||||
|
other: "{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.",
|
||||||
|
}, { domain: domain.name }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, and the accounts already on one stay where they are. A domain comes out only once none of this tenant's accounts are on it.")}</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,221 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* The section is offered to whoever may read tenants. On a server that does not
|
||||||
|
* report Enterprise -- or reports no edition -- the page is only a notice that
|
||||||
|
* tenants are an Enterprise feature: tenants there hold nobody to anything
|
||||||
|
* beyond an ordinary user's permissions, so there is nothing worth creating or
|
||||||
|
* listing. On Enterprise the notice is left out, unless the installation asks
|
||||||
|
* for it (SHOW_ENTERPRISE_NOTICES), as the public demo does so as not to
|
||||||
|
* suggest tenants come without the licence.
|
||||||
|
*/
|
||||||
|
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
||||||
|
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
|
||||||
|
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
|
||||||
|
if (edition !== "enterprise") {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<EnterpriseNotice warn />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Said on every Tenants page, Enterprise or not. */
|
||||||
|
function EnterpriseNotice({ warn }: { warn: boolean }) {
|
||||||
|
return <p className={`admin-notice${warn ? " warn" : ""}`}>{t("Tenants are a Stalwart Enterprise feature.")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice: boolean }) {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const perms = usePermissions();
|
||||||
|
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>
|
||||||
|
|
||||||
|
{notice && <EnterpriseNotice warn={false} />}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,3 +92,45 @@ describe("the account sheet", () => {
|
|||||||
expect(button(host, "Delete account")?.disabled).toBe(true);
|
expect(button(host, "Delete account")?.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account in a tenant has to be on a domain in that tenant -- the live
|
||||||
|
* server refuses anything else -- so the only tenant offered is the domain's.
|
||||||
|
*/
|
||||||
|
describe("an account's tenant", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const tenantCtx: DirectoryContext = {
|
||||||
|
...ctx,
|
||||||
|
domains: [{ id: "d1", name: "example.com", memberTenantId: null }, { id: "d3", name: "acme.example", memberTenantId: "t1" }],
|
||||||
|
tenants: [{ id: "t1", name: "Acme Corp" }, { id: "t2", name: "Globex" }],
|
||||||
|
};
|
||||||
|
const render = async (a: DirectoryAccount) => {
|
||||||
|
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /></Router>);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers only the tenant its domain is in", async () => {
|
||||||
|
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
|
||||||
|
await render(account({ domainId: "d3", memberTenantId: "t1", emailAddress: "[email protected]" }));
|
||||||
|
const options = [...host.querySelectorAll<HTMLOptionElement>('select[aria-label="Tenant"] option')].map((o) => o.textContent);
|
||||||
|
expect(options).toEqual(["No tenant", "Acme Corp"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no choice at all on a domain in no tenant", async () => {
|
||||||
|
signIn([...HELPDESK, "sysTenantGet", "sysTenantQuery"]);
|
||||||
|
await render(account({ domainId: "d1" }));
|
||||||
|
expect(host.querySelector('select[aria-label="Tenant"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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, dkimKeys: 2 } as Record<string, number>,
|
||||||
|
onDomain: 0,
|
||||||
|
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,
|
||||||
|
tenantAccountsOnDomain: vi.fn(async () => api.onDomain),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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, dkimKeys: 2 };
|
||||||
|
api.onDomain = 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, dkimKeys: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taking a domain out of a tenant", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
api.setDomainTenant.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused while the tenant still has accounts on it, which Stalwart would strand", async () => {
|
||||||
|
api.onDomain = 2;
|
||||||
|
signIn(ALL);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
await act(async () => button(host, "Take old-brand.example out of the tenant")!.click());
|
||||||
|
expect(api.setDomainTenant).not.toHaveBeenCalled();
|
||||||
|
expect(host.querySelector(".admin-notice.error")?.textContent).toContain("2 accounts in this tenant are still on old-brand.example");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Router } from "wouter";
|
||||||
|
import { memoryLocation } from "wouter/memory-location";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ queryTenants: vi.fn(async () => ({ ids: ["t1"], total: 1 })) }));
|
||||||
|
vi.mock("@/lib/adminTenants", async (original) => ({
|
||||||
|
...(await original<typeof import("@/lib/adminTenants")>()),
|
||||||
|
queryTenants: api.queryTenants,
|
||||||
|
getTenants: vi.fn(async () => [{ id: "t1", name: "Acme Corp", quotas: {}, usedDiskQuota: 0 }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { TenantsAdmin } = await import("../TenantsAdmin");
|
||||||
|
|
||||||
|
const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
|
||||||
|
const signIn = (edition: string | null, enterpriseNotices = false) =>
|
||||||
|
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
|
||||||
|
|
||||||
|
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
|
||||||
|
describe("the Tenants page", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const render = async () => {
|
||||||
|
const { hook } = memoryLocation({ path: "/admin/tenants" });
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
api.queryTenants.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const edition of ["community", "oss", null]) {
|
||||||
|
it(`shows only the notice on ${edition ?? "a server that reports no edition"}`, async () => {
|
||||||
|
signIn(edition);
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("Tenants are a Stalwart Enterprise feature");
|
||||||
|
expect(host.textContent).not.toContain("New tenant");
|
||||||
|
expect(host.querySelector('input[type="search"]')).toBeNull();
|
||||||
|
expect(host.querySelector(".admin-table")).toBeNull();
|
||||||
|
expect(api.queryTenants).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists and offers tenants on Enterprise, and does not say they are Enterprise", async () => {
|
||||||
|
signIn("enterprise");
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector(".admin-notice")).toBeNull();
|
||||||
|
expect(host.textContent).toContain("New tenant");
|
||||||
|
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Tenants page where the installation asks for Enterprise notices", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says tenants are Enterprise above the list, as the demo does", async () => {
|
||||||
|
signIn("enterprise", true);
|
||||||
|
const { hook } = memoryLocation({ path: "/admin/tenants" });
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
expect(host.querySelector(".admin-notice")?.textContent).toBe("Tenants are a Stalwart Enterprise feature.");
|
||||||
|
expect(host.querySelector(".admin-notice.warn")).toBeNull();
|
||||||
|
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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