Add Tenants to Administration, and let an account be put in one

A tenant is a separate organisation on one server: its own people,
domains and limits, and an administrator who manages only what is in it.
It gets a section under Access, gated by sysTenantQuery and sysTenantGet,
with a notice on a server that does not report Enterprise, where anyone
inside a tenant is held to an ordinary user's permissions.

The panel edits the tenant's name, logo, role and limits. The logo is an
https address, drawn through the image proxy the strict image policy
requires, or an image data URL. Limits change one quotas/<name> pointer
each, so the four ihasmail does not offer keep their values, and an empty
field is no limit. The role is the most anyone inside can be allowed.

Stalwart keeps no list on a tenant -- each account, group, domain, list and
role names its own -- so what a tenant holds is counted with memberTenantId
queries and shown against its limits. Domains are added and taken out from
the tenant's panel, one memberTenantId change each; only a domain in no
tenant can be added, and its accounts stay where they are. Delete is offered
once every count reads zero.

A tenant does nothing until someone administers it, so the account panel
gains a Tenant choice for an administrator who can read tenants: an
Administrator inside a tenant administers that tenant. Nobody moves their
own account.

The mock has a tenant holding a domain and an administrator, a spare domain
to assign, memberTenantId filters on every query, and Stalwart's rule that
only an administrator outside every tenant may move things into one. A test
of taking a domain back out found that the mock's pointer handling dropped a
top-level null instead of storing it, so nothing had ever been cleared that
way; it stores null now, as the server reads it back.

Nothing about tenants has been written on a live server: production has
none. KNOWN-ISSUES says what was read from source.

Thirty-nine new strings and one plural, in all nine catalogues.
This commit is contained in:
2026-09-15 09:28:39 -07:00
parent 7e46ddeb7d
commit fd104a1f34
29 changed files with 1467 additions and 25 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
"quotas/maxAccounts": 30,
"quotas/maxDomains": null,
"quotas/maxGroups": 5,
});
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
});
});
describe("what a tenant holds", () => {
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
const f = (args as { filter: Record<string, unknown> }).filter;
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("moves a domain in and out by its memberTenantId", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
await setDomainTenant("d4", "t1");
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
await setDomainTenant("d4", null);
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
call.mockRestore();
});
});
describe("a tenant's logo", () => {
it("is drawn only from https or an image data URL", () => {
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
expect(drawableLogo("javascript:alert(1)")).toBeNull();
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
expect(drawableLogo(null)).toBeNull();
});
});
+2 -1
View File
@@ -27,7 +27,7 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole
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";
@@ -63,6 +63,7 @@ export function adminSections(perms: Permissions): AdminSection[] {
// 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, "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, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
return out;
+13 -4
View File
@@ -54,6 +54,8 @@ export interface DirectoryAccount {
usedDiskQuota?: number;
aliases?: Record<string, EmailAlias>;
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>;
createdAt?: string;
}
@@ -65,7 +67,7 @@ export interface DirectoryDomain {
const ACCOUNT_PROPERTIES = [
"@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. */
@@ -143,6 +145,8 @@ export interface NewAccount {
password: string;
roles: UserRoles;
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> {
@@ -159,6 +163,7 @@ export async function createAccount(input: NewAccount): Promise<string> {
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
aliases: {},
memberGroupIds: {},
...(input.memberTenantId ? { memberTenantId: input.memberTenantId } : {}),
// Required on create. Turning it on is one-way and not offered here.
encryptionAtRest: { "@type": "Disabled" },
},
@@ -229,7 +234,7 @@ const VALIDATOR_MESSAGES: Record<string, () => string> = {
};
/** What kind of thing a refusal was about, where the wording has to differ. */
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.
@@ -282,7 +287,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
? t("Your organisation has reached the number of mailing lists it is allowed.")
: object === "role"
? 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":
return t("Something still depends on this, so the server kept it.");
case "notFound":
@@ -294,7 +301,9 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
? t("This mailing list no longer exists. Someone may have deleted it.")
: object === "role"
? 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":
return t("Too many attempts. Please wait a few minutes and try again.");
case "tooLarge":
+1
View File
@@ -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 === "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 === "Domain") parts.push(plural(n, { one: "{n} domain", other: "{n} domains" }));
else if (kind === "Authentication") parts.push(t("the default roles"));
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
}
+174
View File
@@ -0,0 +1,174 @@
import { client } from "@/jmap/client";
import { t } from "@/lib/i18n";
import { DirectoryError } from "@/lib/adminDirectory";
import { DomainError } from "@/lib/adminDomains";
/**
* Tenants, from Stalwart 0.16's directory.
*
* `x:Tenant` behind `sysTenant*`, an Enterprise feature: on a Community server
* the objects exist, but anyone inside a tenant is held to a plain user's
* permissions. A tenant is a name, an optional logo, the roles its members may
* at most have, and quotas. It holds no list of what is in it -- membership
* runs the other way, as `memberTenantId` on accounts, groups, domains,
* mailing lists, roles and DKIM keys.
*
* Only an account outside every tenant may set `memberTenantId` (Stalwart
* refuses "Cannot modify memberTenantId property" to anyone else), and inside a
* tenant the server scopes every query to it and fills it in on create. Shapes
* from the 0.16.22 schema:
*
* - `quotas` is a map from a `TenantStorageQuota` name to a number: counts for
* accounts, groups, domains and the rest, bytes for `maxDiskQuota`. A quota
* that is absent is no limit.
* - `logo` is a URL or a data URL, or null.
*/
export type TenantRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
export interface DirectoryTenant {
id: string;
name: string;
logo?: string | null;
roles?: TenantRoles;
quotas?: Record<string, number>;
usedDiskQuota?: number;
createdAt?: string;
}
/** The quotas ihasmail offers, in the order they are shown. Disk space is bytes; the rest are counts. */
export const TENANT_QUOTAS = ["maxAccounts", "maxGroups", "maxMailingLists", "maxDomains", "maxRoles", "maxDkimKeys", "maxDiskQuota"] as const;
export type TenantQuota = (typeof TENANT_QUOTAS)[number];
/** What belongs to a tenant, and how each is counted. */
export const TENANT_MEMBERS = [
{ key: "accounts", method: "x:Account/query", filter: { "@type": "User" }, quota: "maxAccounts" },
{ key: "groups", method: "x:Account/query", filter: { "@type": "Group" }, quota: "maxGroups" },
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
] as const;
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
const TENANT_PROPERTIES = ["name", "logo", "roles", "quotas", "usedDiskQuota", "createdAt"];
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[]; linkedObjects?: Array<{ object?: string; id?: string }> } | null> | undefined> & {
created?: Record<string, { id: string }>;
};
function throwIfRefused(res: SetResponse, key: "notCreated" | "notUpdated" | "notDestroyed"): void {
const first = Object.values(res[key] ?? {})[0];
if (first) throw new DomainError(first);
}
export async function queryTenants(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
const res = await client.call<{ ids?: string[]; total?: number }>("x:Tenant/query", {
...(opts.text?.trim() ? { filter: { text: opts.text.trim() } } : {}),
position: opts.position ?? 0,
...(opts.limit ? { limit: opts.limit } : {}),
calculateTotal: true,
});
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
}
export async function getTenants(ids: string[]): Promise<DirectoryTenant[]> {
if (!ids.length) return [];
const res = await client.call<{ list: DirectoryTenant[] }>("x:Tenant/get", { ids, properties: TENANT_PROPERTIES });
const byId = new Map(res.list.map((x) => [x.id, x]));
return ids.map((id) => byId.get(id)).filter((x): x is DirectoryTenant => Boolean(x));
}
/** Every tenant's id and name, for pickers. */
export async function listTenantNames(): Promise<Array<{ id: string; name: string }>> {
const q = await client.call<{ ids?: string[] }>("x:Tenant/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return [];
const res = await client.call<{ list: Array<{ id: string; name: string }> }>("x:Tenant/get", { ids: q.ids, properties: ["name"] });
return res.list.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* How many of each kind of thing a tenant holds. A count that fails -- the
* viewer may not read that kind at all -- is left out rather than shown as
* none, which would read as "safe to delete".
*/
export async function countTenantMembers(tenantId: string): Promise<Partial<Record<TenantMemberKind, number>>> {
const out: Partial<Record<TenantMemberKind, number>> = {};
await Promise.all(
TENANT_MEMBERS.map(async (m) => {
try {
const res = await client.call<{ total?: number }>(m.method, { filter: { ...m.filter, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
if (typeof res.total === "number") out[m.key] = res.total;
} catch {
/* left out */
}
}),
);
return out;
}
/** The domains in a tenant, and those in none, which are the ones that can be added. */
export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> }> {
const q = await client.call<{ ids?: string[] }>("x:Domain/query", { limit: client.maxObjectsInGet });
if (!q.ids?.length) return { inTenant: [], unassigned: [] };
const res = await client.call<{ list: Array<{ id: string; name: string; memberTenantId?: string | null }> }>("x:Domain/get", { ids: q.ids, properties: ["name", "memberTenantId"] });
const sorted = res.list.sort((a, b) => a.name.localeCompare(b.name));
return {
inTenant: sorted.filter((d) => d.memberTenantId === tenantId).map(({ id, name }) => ({ id, name })),
unassigned: sorted.filter((d) => !d.memberTenantId).map(({ id, name }) => ({ id, name })),
};
}
/** Put a domain in a tenant, or take it out with null. */
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });
throwIfRefused(res, "notUpdated");
}
export interface NewTenant {
name: string;
logo: string | null;
roles: TenantRoles;
quotas: Record<string, number>;
}
export async function createTenant(input: NewTenant): Promise<string> {
const res = await client.call<SetResponse>("x:Tenant/set", {
create: { n: { name: input.name.trim(), logo: input.logo, roles: input.roles, permissions: { "@type": "Inherit" }, quotas: input.quotas } },
});
throwIfRefused(res, "notCreated");
const id = res.created?.n?.id;
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the tenant was created."));
return id;
}
export async function updateTenant(id: string, patch: Record<string, unknown>): Promise<void> {
if (!Object.keys(patch).length) return;
const res = await client.call<SetResponse>("x:Tenant/set", { update: { [id]: patch } });
throwIfRefused(res, "notUpdated");
}
export async function destroyTenant(id: string): Promise<void> {
const res = await client.call<SetResponse>("x:Tenant/set", { destroy: [id] });
throwIfRefused(res, "notDestroyed");
}
/**
* The quota changes as one pointer each, so a quota ihasmail does not offer
* (OAuth clients, DNS servers, directories, ACME providers) keeps its value.
*/
export function quotasPatch(before: Record<string, number> | undefined, after: Partial<Record<TenantQuota, number | null>>): Record<string, number | null> {
const patch: Record<string, number | null> = {};
for (const key of TENANT_QUOTAS) {
if (!(key in after)) continue;
const next = after[key] ?? null;
const was = before?.[key] ?? null;
if (next !== was) patch[`quotas/${key}`] = next;
}
return patch;
}
/** A logo worth showing: an https or data image URL. Anything else is kept but not drawn. */
export function drawableLogo(logo: string | null | undefined): string | null {
if (!logo) return null;
return /^https:\/\//i.test(logo) || /^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(logo) ? logo : null;
}
+40
View File
@@ -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.",
"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.",
"No tenant": "Kein Mandant",
"You can't move your own account into a tenant.": "Sie können Ihr eigenes Konto nicht in einen Mandanten verschieben.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Ein Konto in einem Mandanten ist durch dessen Rolle begrenzt und zählt zu dessen Limits, und Administrator bedeutet Administrator dieses Mandanten.",
"Tenants": "Mandanten",
"Storage in GB": "Speicher in GB",
"Default tenant roles": "Standardrollen für Mandanten",
"A tenant needs a name.": "Ein Mandant braucht einen Namen.",
"New tenant": "Neuer Mandant",
"{used} used": "{used} belegt",
"Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.",
"Logo": "Logo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Stalwart zeigt es den Personen des Mandanten dort, wo es ein Logo zeigt.",
"What it holds": "Enthält",
"{n} of {limit}": "{n} von {limit}",
"Limits": "Limits",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
"Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…",
"It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.",
"Create tenant": "Mandant anlegen",
"Added {domain} to {tenant}": "{domain} zu {tenant} hinzugefügt",
"Took {domain} out of {tenant}": "{domain} aus {tenant} entfernt",
"Take {domain} out of the tenant": "{domain} aus dem Mandanten entfernen",
"No domains in this tenant yet": "Noch keine Domains in diesem Mandanten",
"Domain to add": "Hinzuzufügende Domain",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Nur Domains ohne Mandanten können hinzugefügt werden. Die Konten auf einer Domain bleiben, wo sie sind; verschieben Sie jedes in seinem eigenen Bereich.",
"An empty tenant can be deleted.": "Ein leerer Mandant kann gelöscht werden.",
"Delete tenant…": "Mandant löschen…",
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
"Delete tenant": "Mandant löschen",
"Separate organisations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Mandanten sind eine Funktion von Stalwart Enterprise. Dieser Server meldet kein Enterprise, daher hat jeder in einem Mandanten nur die Berechtigungen eines normalen Benutzers.",
"Search tenants": "Mandanten durchsuchen",
"No tenants match": "Keine passenden Mandanten",
"No tenants yet": "Noch keine Mandanten",
"Account limit": "Kontenlimit",
"The server allows no more tenants.": "Der Server erlaubt keine weiteren Mandanten.",
"This tenant no longer exists. Someone may have deleted it.": "Dieser Mandant existiert nicht mehr. Jemand hat ihn möglicherweise gelöscht.",
"The server did not say whether the tenant was created.": "Der Server hat nicht mitgeteilt, ob der Mandant angelegt wurde.",
"User": "Benutzer",
"Administrator": "Administrator",
"Custom role": "Eigene Rolle",
@@ -1676,6 +1715,7 @@ export const catalog: Catalog = {
"{n} recipients": { one: "{n} Empfänger", other: "{n} Empfänger" },
"Grants {n} permissions": { one: "Gewährt {n} Berechtigung", other: "Gewährt {n} Berechtigungen" },
"{n} roles": { one: "{n} Rolle", other: "{n} Rollen" },
"{n} tenants": { one: "{n} Mandant", other: "{n} Mandanten" },
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -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.",
"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ó.",
"No tenant": "Sin inquilino",
"You can't move your own account into a tenant.": "No puede mover su propia cuenta a un inquilino.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Una cuenta de un inquilino está limitada por el rol del inquilino y cuenta para sus límites, y Administrador significa administrador de ese inquilino.",
"Tenants": "Inquilinos",
"Storage in GB": "Almacenamiento en GB",
"Default tenant roles": "Roles de inquilino predeterminados",
"A tenant needs a name.": "Un inquilino necesita un nombre.",
"New tenant": "Nuevo inquilino",
"{used} used": "{used} usados",
"Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.",
"Logo": "Logotipo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL data de una imagen. Stalwart la muestra a las personas del inquilino donde muestra un logotipo.",
"What it holds": "Contenido",
"{n} of {limit}": "{n} de {limit}",
"Limits": "Límites",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
"Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…",
"It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.",
"Create tenant": "Crear inquilino",
"Added {domain} to {tenant}": "{domain} añadido a {tenant}",
"Took {domain} out of {tenant}": "{domain} quitado de {tenant}",
"Take {domain} out of the tenant": "Quitar {domain} del inquilino",
"No domains in this tenant yet": "Aún no hay dominios en este inquilino",
"Domain to add": "Dominio para añadir",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Solo se pueden añadir dominios que no estén en ningún inquilino. Las cuentas que ya están en un dominio se quedan donde están; mueva cada una desde su propio panel.",
"An empty tenant can be deleted.": "Un inquilino vacío se puede eliminar.",
"Delete tenant…": "Eliminar inquilino…",
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
"Delete tenant": "Eliminar inquilino",
"Separate organisations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Los inquilinos son una función de Stalwart Enterprise. Este servidor no indica Enterprise, así que cualquiera dentro de un inquilino solo tiene los permisos de un usuario normal.",
"Search tenants": "Buscar inquilinos",
"No tenants match": "Ningún inquilino coincide",
"No tenants yet": "Aún no hay inquilinos",
"Account limit": "Límite de cuentas",
"The server allows no more tenants.": "El servidor no permite más inquilinos.",
"This tenant no longer exists. Someone may have deleted it.": "Este inquilino ya no existe. Puede que alguien lo haya eliminado.",
"The server did not say whether the tenant was created.": "El servidor no indicó si el inquilino se creó.",
"User": "Usuario",
"Administrator": "Administrador",
"Custom role": "Rol personalizado",
@@ -1649,6 +1688,7 @@ export const catalog: Catalog = {
"{n} recipients": { one: "{n} destinatario", other: "{n} destinatarios" },
"Grants {n} permissions": { one: "Concede {n} permiso", other: "Concede {n} permisos" },
"{n} roles": { one: "{n} rol", other: "{n} roles" },
"{n} tenants": { one: "{n} inquilino", other: "{n} inquilinos" },
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -256,6 +256,45 @@ export const catalog: Catalog = {
"This role no longer exists. Someone may have deleted it.": "Ce rôle nexiste plus. Quelquun la peut-être supprimé.",
"the default roles": "les rôles par défaut",
"The server did not say whether the role was created.": "Le serveur na pas indiqué si le rôle a été créé.",
"No tenant": "Aucun locataire",
"You can't move your own account into a tenant.": "Vous ne pouvez pas déplacer votre propre compte dans un locataire.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Un compte dans un locataire est limité par le rôle du locataire et compte dans ses limites, et Administrateur signifie administrateur de ce locataire.",
"Tenants": "Locataires",
"Storage in GB": "Stockage en Go",
"Default tenant roles": "Rôles de locataire par défaut",
"A tenant needs a name.": "Un locataire a besoin dun 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 dimage. Stalwart laffiche 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à dune 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 dautres éléments. Déplacez-les dabord.",
"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 linstant",
"Domain to add": "Domaine à ajouter",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Seuls les domaines hors de tout locataire peuvent être ajoutés. Les comptes déjà sur un domaine restent où ils sont ; déplacez chacun depuis son propre panneau.",
"An empty tenant can be deleted.": "Un locataire vide peut être supprimé.",
"Delete tenant…": "Supprimer le locataire…",
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les dabord.",
"Delete tenant": "Supprimer le locataire",
"Separate organisations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Les locataires sont une fonctionnalité de Stalwart Enterprise. Ce serveur ne se déclare pas Enterprise : toute personne dans un locataire na donc que les autorisations dun utilisateur ordinaire.",
"Search tenants": "Rechercher des locataires",
"No tenants match": "Aucun locataire ne correspond",
"No tenants yet": "Aucun locataire pour linstant",
"Account limit": "Limite de comptes",
"The server allows no more tenants.": "Le serveur nautorise pas dautres locataires.",
"This tenant no longer exists. Someone may have deleted it.": "Ce locataire nexiste plus. Quelquun la peut-être supprimé.",
"The server did not say whether the tenant was created.": "Le serveur na pas indiqué si le locataire a été créé.",
"User": "Utilisateur",
"Administrator": "Administrateur",
"Custom role": "Rôle personnalisé",
@@ -1654,6 +1693,7 @@ export const catalog: Catalog = {
"{n} recipients": { one: "{n} destinataire", other: "{n} destinataires" },
"Grants {n} permissions": { one: "Accorde {n} autorisation", other: "Accorde {n} autorisations" },
"{n} roles": { one: "{n} rôle", other: "{n} rôles" },
"{n} tenants": { one: "{n} locataire", other: "{n} locataires" },
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -250,6 +250,45 @@ export const catalog: Catalog = {
"This role no longer exists. Someone may have deleted it.": "このロールはもう存在しません。誰かが削除した可能性があります。",
"the default roles": "既定のロール設定",
"The server did not say whether the role was created.": "ロールが作成されたかどうか、サーバーから返答がありませんでした。",
"No tenant": "テナントなし",
"You can't move your own account into a tenant.": "自分のアカウントをテナントに移すことはできません。",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "テナント内のアカウントは、テナントのロールによって制限され、テナントの上限に数えられます。また、管理者はそのテナントの管理者を意味します。",
"Tenants": "テナント",
"Storage in GB": "ストレージ (GB)",
"Default tenant roles": "テナントの既定ロール",
"A tenant needs a name.": "テナントには名前が必要です。",
"New tenant": "新しいテナント",
"{used} used": "{used} 使用中",
"Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。",
"Logo": "ロゴ",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https のアドレス、または画像の data URL です。Stalwart はロゴを表示する場所で、テナントの利用者に表示します。",
"What it holds": "含まれるもの",
"{n} of {limit}": "{limit} 件中 {n} 件",
"Limits": "上限",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart は上限を超える作成を拒否します。空欄は上限なしです。",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。",
"Checking what is still in this tenant…": "このテナントに残っているものを確認しています…",
"It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。",
"Create tenant": "テナントを作成",
"Added {domain} to {tenant}": "{domain} を {tenant} に追加しました",
"Took {domain} out of {tenant}": "{domain} を {tenant} から外しました",
"Take {domain} out of the tenant": "{domain} をテナントから外す",
"No domains in this tenant yet": "このテナントにはまだドメインがありません",
"Domain to add": "追加するドメイン",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "追加できるのは、どのテナントにも属していないドメインだけです。ドメイン上の既存のアカウントはそのまま残るため、それぞれのパネルから移動してください。",
"An empty tenant can be deleted.": "空のテナントは削除できます。",
"Delete tenant…": "テナントを削除…",
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
"Delete tenant": "テナントを削除",
"Separate organisations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "テナントは Stalwart Enterprise の機能です。このサーバーは Enterprise と報告していないため、テナント内の利用者は通常のユーザーの権限しか持ちません。",
"Search tenants": "テナントを検索",
"No tenants match": "一致するテナントはありません",
"No tenants yet": "まだテナントがありません",
"Account limit": "アカウント上限",
"The server allows no more tenants.": "サーバーはこれ以上のテナントを許可していません。",
"This tenant no longer exists. Someone may have deleted it.": "このテナントはもう存在しません。誰かが削除した可能性があります。",
"The server did not say whether the tenant was created.": "テナントが作成されたかどうか、サーバーから返答がありませんでした。",
"User": "ユーザー",
"Administrator": "管理者",
"Custom role": "カスタムロール",
@@ -1657,6 +1696,7 @@ export const catalog: Catalog = {
"{n} recipients": { other: "{n} 件の受信者" },
"Grants {n} permissions": { other: "{n} 件の権限を付与" },
"{n} roles": { other: "{n} 件のロール" },
"{n} tenants": { other: "{n} 件のテナント" },
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
"{n} other items": { other: "その他 {n} 件" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -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.",
"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.",
"No tenant": "Geen tenant",
"You can't move your own account into a tenant.": "U kunt uw eigen account niet naar een tenant verplaatsen.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Een account in een tenant wordt beperkt door de rol van de tenant en telt mee voor de limieten, en Beheerder betekent beheerder van die tenant.",
"Tenants": "Tenants",
"Storage in GB": "Opslag in GB",
"Default tenant roles": "Standaardrollen voor tenants",
"A tenant needs a name.": "Een tenant heeft een naam nodig.",
"New tenant": "Nieuwe tenant",
"{used} used": "{used} gebruikt",
"Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.",
"Logo": "Logo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. Stalwart toont het aan de mensen van de tenant waar het een logo toont.",
"What it holds": "Inhoud",
"{n} of {limit}": "{n} van {limit}",
"Limits": "Limieten",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
"Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…",
"It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.",
"Create tenant": "Tenant aanmaken",
"Added {domain} to {tenant}": "{domain} aan {tenant} toegevoegd",
"Took {domain} out of {tenant}": "{domain} uit {tenant} gehaald",
"Take {domain} out of the tenant": "{domain} uit de tenant halen",
"No domains in this tenant yet": "Nog geen domeinen in deze tenant",
"Domain to add": "Toe te voegen domein",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Alleen domeinen die in geen enkele tenant zitten kunnen worden toegevoegd. De accounts op een domein blijven waar ze zijn; verplaats elk vanuit het eigen paneel.",
"An empty tenant can be deleted.": "Een lege tenant kan worden verwijderd.",
"Delete tenant…": "Tenant verwijderen…",
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
"Delete tenant": "Tenant verwijderen",
"Separate organisations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Tenants zijn een functie van Stalwart Enterprise. Deze server meldt geen Enterprise, dus iedereen in een tenant heeft alleen de rechten van een gewone gebruiker.",
"Search tenants": "Tenants zoeken",
"No tenants match": "Geen tenants gevonden",
"No tenants yet": "Nog geen tenants",
"Account limit": "Accountlimiet",
"The server allows no more tenants.": "De server staat geen tenants meer toe.",
"This tenant no longer exists. Someone may have deleted it.": "Deze tenant bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
"The server did not say whether the tenant was created.": "De server heeft niet gemeld of de tenant is aangemaakt.",
"User": "Gebruiker",
"Administrator": "Beheerder",
"Custom role": "Aangepaste rol",
@@ -1645,6 +1684,7 @@ export const catalog: Catalog = {
"{n} recipients": { one: "{n} ontvanger", other: "{n} ontvangers" },
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -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.",
"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.",
"No tenant": "Nenhum locatário",
"You can't move your own account into a tenant.": "Você não pode mover sua própria conta para um locatário.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Uma conta em um locatário é limitada pela função do locatário e conta para os limites dele, e Administrador significa administrador desse locatário.",
"Tenants": "Locatários",
"Storage in GB": "Armazenamento em GB",
"Default tenant roles": "Funções padrão de locatário",
"A tenant needs a name.": "Um locatário precisa de um nome.",
"New tenant": "Novo locatário",
"{used} used": "{used} usados",
"Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.",
"Logo": "Logotipo",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL data de uma imagem. O Stalwart a mostra às pessoas do locatário onde mostra um logotipo.",
"What it holds": "O que contém",
"{n} of {limit}": "{n} de {limit}",
"Limits": "Limites",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "O Stalwart se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.",
"Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…",
"It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.",
"Create tenant": "Criar locatário",
"Added {domain} to {tenant}": "{domain} adicionado a {tenant}",
"Took {domain} out of {tenant}": "{domain} retirado de {tenant}",
"Take {domain} out of the tenant": "Retirar {domain} do locatário",
"No domains in this tenant yet": "Nenhum domínio neste locatário ainda",
"Domain to add": "Domínio a adicionar",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Só podem ser adicionados domínios que não estejam em nenhum locatário. As contas já existentes num domínio ficam onde estão; mova cada uma pelo próprio painel.",
"An empty tenant can be deleted.": "Um locatário vazio pode ser excluído.",
"Delete tenant…": "Excluir locatário…",
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
"Delete tenant": "Excluir locatário",
"Separate organisations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Locatários são um recurso do Stalwart Enterprise. Este servidor não informa ser Enterprise, então qualquer pessoa num locatário tem só as permissões de um usuário comum.",
"Search tenants": "Pesquisar locatários",
"No tenants match": "Nenhum locatário corresponde",
"No tenants yet": "Nenhum locatário ainda",
"Account limit": "Limite de contas",
"The server allows no more tenants.": "O servidor não permite mais locatários.",
"This tenant no longer exists. Someone may have deleted it.": "Este locatário não existe mais. Alguém pode tê-lo excluído.",
"The server did not say whether the tenant was created.": "O servidor não informou se o locatário foi criado.",
"User": "Usuário",
"Administrator": "Administrador",
"Custom role": "Função personalizada",
@@ -1652,6 +1691,7 @@ export const catalog: Catalog = {
"{n} recipients": { one: "{n} destinatário", other: "{n} destinatários" },
"Grants {n} permissions": { one: "Concede {n} permissão", other: "Concede {n} permissões" },
"{n} roles": { one: "{n} função", other: "{n} funções" },
"{n} tenants": { one: "{n} locatário", other: "{n} locatários" },
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -253,6 +253,45 @@ export const catalog: Catalog = {
"This role no longer exists. Someone may have deleted it.": "Этой роли больше нет. Возможно, её кто-то удалил.",
"the default roles": "настройки ролей по умолчанию",
"The server did not say whether the role was created.": "Сервер не сообщил, создана ли роль.",
"No tenant": "Без арендатора",
"You can't move your own account into a tenant.": "Нельзя переместить собственную учётную запись в арендатора.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Учётная запись арендатора ограничена ролью арендатора и учитывается в его лимитах, а «Администратор» означает администратора этого арендатора.",
"Tenants": "Арендаторы",
"Storage in GB": "Хранилище, ГБ",
"Default tenant roles": "Роли арендатора по умолчанию",
"A tenant needs a name.": "Арендатору нужно название.",
"New tenant": "Новый арендатор",
"{used} used": "Занято {used}",
"Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.",
"Logo": "Логотип",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Stalwart показывает его людям арендатора там, где показывает логотип.",
"What it holds": "Содержимое",
"{n} of {limit}": "{n} из {limit}",
"Limits": "Лимиты",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не даёт создать больше, чем позволяет лимит. Пустое поле — без лимита.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.",
"Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…",
"It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.",
"Create tenant": "Создать арендатора",
"Added {domain} to {tenant}": "{domain} добавлен в {tenant}",
"Took {domain} out of {tenant}": "{domain} убран из {tenant}",
"Take {domain} out of the tenant": "Убрать {domain} из арендатора",
"No domains in this tenant yet": "У этого арендатора пока нет доменов",
"Domain to add": "Домен для добавления",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Добавить можно только домены, не принадлежащие ни одному арендатору. Учётные записи на домене остаются на месте; переносите каждую из её собственной панели.",
"An empty tenant can be deleted.": "Пустого арендатора можно удалить.",
"Delete tenant…": "Удалить арендатора…",
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
"Delete tenant": "Удалить арендатора",
"Separate organisations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Арендаторы — функция Stalwart Enterprise. Этот сервер не сообщает о редакции Enterprise, поэтому у любого в арендаторе только разрешения обычного пользователя.",
"Search tenants": "Поиск арендаторов",
"No tenants match": "Нет подходящих арендаторов",
"No tenants yet": "Арендаторов пока нет",
"Account limit": "Лимит учётных записей",
"The server allows no more tenants.": "Сервер не позволяет больше арендаторов.",
"This tenant no longer exists. Someone may have deleted it.": "Этого арендатора больше нет. Возможно, его кто-то удалил.",
"The server did not say whether the tenant was created.": "Сервер не сообщил, создан ли арендатор.",
"User": "Пользователь",
"Administrator": "Администратор",
"Custom role": "Особая роль",
@@ -1651,6 +1690,7 @@ export const catalog: Catalog = {
"{n} recipients": { 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} tenants": { one: "{n} арендатор", few: "{n} арендатора", many: "{n} арендаторов", other: "{n} арендатора" },
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -247,6 +247,45 @@ export const catalog: Catalog = {
"This role no longer exists. Someone may have deleted it.": "Цієї ролі більше немає. Можливо, її хтось видалив.",
"the default roles": "налаштування ролей за замовчуванням",
"The server did not say whether the role was created.": "Сервер не повідомив, чи створено роль.",
"No tenant": "Без орендаря",
"You can't move your own account into a tenant.": "Не можна перемістити власний обліковий запис до орендаря.",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "Обліковий запис орендаря обмежений роллю орендаря й зараховується до його лімітів, а «Адміністратор» означає адміністратора цього орендаря.",
"Tenants": "Орендарі",
"Storage in GB": "Сховище, ГБ",
"Default tenant roles": "Ролі орендаря за замовчуванням",
"A tenant needs a name.": "Орендарю потрібна назва.",
"New tenant": "Новий орендар",
"{used} used": "Зайнято {used}",
"Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.",
"Logo": "Логотип",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Stalwart показує його людям орендаря там, де показує логотип.",
"What it holds": "Вміст",
"{n} of {limit}": "{n} з {limit}",
"Limits": "Ліміти",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не дає створити більше, ніж дозволяє ліміт. Порожнє поле — без ліміту.",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.",
"Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…",
"It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.",
"Create tenant": "Створити орендаря",
"Added {domain} to {tenant}": "{domain} додано до {tenant}",
"Took {domain} out of {tenant}": "{domain} прибрано з {tenant}",
"Take {domain} out of the tenant": "Прибрати {domain} з орендаря",
"No domains in this tenant yet": "У цього орендаря поки немає доменів",
"Domain to add": "Домен для додавання",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "Додати можна лише домени, що не належать жодному орендарю. Облікові записи на домені лишаються на місці; переносьте кожен з його власної панелі.",
"An empty tenant can be deleted.": "Порожнього орендаря можна видалити.",
"Delete tenant…": "Видалити орендаря…",
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
"Delete tenant": "Видалити орендаря",
"Separate organisations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "Орендарі — функція Stalwart Enterprise. Цей сервер не повідомляє про редакцію Enterprise, тож будь-хто в орендарі має лише дозволи звичайного користувача.",
"Search tenants": "Пошук орендарів",
"No tenants match": "Немає відповідних орендарів",
"No tenants yet": "Орендарів поки немає",
"Account limit": "Ліміт облікових записів",
"The server allows no more tenants.": "Сервер не дозволяє більше орендарів.",
"This tenant no longer exists. Someone may have deleted it.": "Цього орендаря більше немає. Можливо, його хтось видалив.",
"The server did not say whether the tenant was created.": "Сервер не повідомив, чи створено орендаря.",
"User": "Користувач",
"Administrator": "Адміністратор",
"Custom role": "Власна роль",
@@ -1645,6 +1684,7 @@ export const catalog: Catalog = {
"{n} recipients": { 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} tenants": { one: "{n} орендар", few: "{n} орендарі", many: "{n} орендарів", other: "{n} орендаря" },
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
// ── Administration ────────────────────────────────────────────────
+40
View File
@@ -249,6 +249,45 @@ export const catalog: Catalog = {
"This role no longer exists. Someone may have deleted it.": "此角色已不存在。可能已被他人删除。",
"the default roles": "默认角色设置",
"The server did not say whether the role was created.": "服务器未说明角色是否已创建。",
"No tenant": "无租户",
"You can't move your own account into a tenant.": "您不能将自己的账户移入租户。",
"An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.": "租户中的账户受租户角色限制,并计入租户的限额;管理员指该租户的管理员。",
"Tenants": "租户",
"Storage in GB": "存储 (GB)",
"Default tenant roles": "默认租户角色",
"A tenant needs a name.": "租户需要一个名称。",
"New tenant": "新建租户",
"{used} used": "已用 {used}",
"Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。",
"Logo": "徽标",
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https 地址或图片的 data URL。Stalwart 会在显示徽标的地方向租户成员展示它。",
"What it holds": "包含内容",
"{n} of {limit}": "{n}/{limit}",
"Limits": "限额",
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart 会拒绝超出限额的创建。留空表示不限。",
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。",
"Checking what is still in this tenant…": "正在检查此租户中还有什么…",
"It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。",
"Create tenant": "创建租户",
"Added {domain} to {tenant}": "已将 {domain} 添加到 {tenant}",
"Took {domain} out of {tenant}": "已将 {domain} 移出 {tenant}",
"Take {domain} out of the tenant": "将 {domain} 移出租户",
"No domains in this tenant yet": "此租户中还没有域名",
"Domain to add": "要添加的域名",
"Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.": "只能添加不属于任何租户的域名。域名上已有的账户保持不变;请在各自的面板中移动。",
"An empty tenant can be deleted.": "空租户可以删除。",
"Delete tenant…": "删除租户…",
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
"Delete tenant": "删除租户",
"Separate organisations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
"Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.": "租户是 Stalwart Enterprise 的功能。此服务器未报告为 Enterprise,因此租户中的任何人都只有普通用户的权限。",
"Search tenants": "搜索租户",
"No tenants match": "没有匹配的租户",
"No tenants yet": "还没有租户",
"Account limit": "账户限额",
"The server allows no more tenants.": "服务器不允许再创建租户。",
"This tenant no longer exists. Someone may have deleted it.": "此租户已不存在。可能已被他人删除。",
"The server did not say whether the tenant was created.": "服务器未说明租户是否已创建。",
"User": "用户",
"Administrator": "管理员",
"Custom role": "自定义角色",
@@ -1656,6 +1695,7 @@ export const catalog: Catalog = {
"{n} recipients": { other: "{n} 位收件人" },
"Grants {n} permissions": { other: "授予 {n} 项权限" },
"{n} roles": { other: "{n} 个角色" },
"{n} tenants": { other: "{n} 个租户" },
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
"{n} other items": { other: "其他 {n} 项" },
// ── Administration ────────────────────────────────────────────────
+3
View File
@@ -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 .mono { font-family: var(--font-mono); font-size: .8em; word-break: break-all; }
.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 dt { color: var(--fg-muted); }
.admin-kv dd { margin: 0; }
+15 -1
View File
@@ -85,6 +85,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
const [role, setRole] = useState(roleKey(account?.roles));
const [quota, setQuota] = useState(gibOf(account?.quotas?.[DISK_QUOTA]));
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(account?.aliases ?? {}));
const [tenantId, setTenantId] = useState(account?.memberTenantId ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -132,7 +133,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
setError(t("An account needs an address."));
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 }));
onCreated(id);
return;
@@ -140,6 +141,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
const patch: Record<string, unknown> = {};
if ((account.description ?? "") !== description) patch.description = description.trim() || null;
if (roleKey(account.roles) !== role) patch.roles = rolesFromKey(role);
if ((account.memberTenantId ?? "") !== tenantId) patch.memberTenantId = tenantId || null;
if ((account.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(account.quotas, bytesOf(quota));
const before = JSON.stringify(aliasList(Object.values(account.aliases ?? {})));
if (before !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
@@ -245,6 +247,18 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
{self ? t("You can't change your own role.") : t("Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.")}
</p>
{ctx.tenants && (ctx.tenants.length > 0 || tenantId) && (
<>
<h3>{t("Tenant")}</h3>
<select className="input admin-wide" aria-label={t("Tenant")} value={tenantId} disabled={!editable || self} onChange={(e) => setTenantId(e.target.value)}>
<option value="">{t("No tenant")}</option>
{ctx.tenants.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
{tenantId && !ctx.tenants.some((x) => x.id === tenantId) && <option value={tenantId}>{tenantId}</option>}
</select>
<p className="hint">{self ? t("You can't move your own account into a tenant.") : t("An account in a tenant is limited by the tenant's role and counts towards its limits, and Administrator means administrator of that tenant.")}</p>
</>
)}
<h3>{t("Storage")}</h3>
{!creating && (
<p className="hint" style={{ marginTop: 0 }}>
+5 -1
View File
@@ -21,6 +21,7 @@ import { Avatar, Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { AccountSheet } from "./AccountSheet";
import { listTenantNames } from "@/lib/adminTenants";
const PAGE_SIZE = 50;
@@ -38,6 +39,7 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
const [groups, setGroups] = useState<Map<string, DirectoryAccount>>(new Map());
const [loose, setLoose] = useState<DirectoryAccount | null>(null);
const [tenants, setTenants] = useState<Array<{ id: string; name: string }> | null>(null);
// Typing is not a query per keystroke.
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, "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()));
if (can(perms, "Tenant", "Query") && can(perms, "Tenant", "Get")) void listTenantNames().then(setTenants, () => setTenants(null));
}, [perms, reload]);
// 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)),
roles,
groups,
tenants,
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 close = () => navigate("/admin/accounts");
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
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 { t } from "@/lib/i18n";
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} /> },
groups: { group: "Directory", label: "Groups", icon: <UsersRound 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} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
};
+2
View File
@@ -7,6 +7,7 @@ import { DomainsAdmin } from "./DomainsAdmin";
import { GroupsAdmin } from "./GroupsAdmin";
import { ListsAdmin } from "./ListsAdmin";
import { RolesAdmin } from "./RolesAdmin";
import { TenantsAdmin } from "./TenantsAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions";
@@ -15,6 +16,7 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
accounts: (id) => <AccountsAdmin selectedId={id} />,
groups: (id) => <GroupsAdmin selectedId={id} />,
lists: (id) => <ListsAdmin selectedId={id} />,
tenants: (id) => <TenantsAdmin selectedId={id} />,
roles: (id) => <RolesAdmin selectedId={id} />,
domains: (id) => <DomainsAdmin selectedId={id} />,
};
+389
View File
@@ -0,0 +1,389 @@
import { useEffect, useMemo, useState } from "react";
import { Globe, Plus, Trash2, X } from "lucide-react";
import { can, canGrantRole, type RoleDef } from "@/lib/adminAccess";
import { describeDirectoryError } from "@/lib/adminDirectory";
import { describeLinked, DomainError } from "@/lib/adminDomains";
import {
countTenantMembers,
createTenant,
destroyTenant,
drawableLogo,
quotasPatch,
setDomainTenant,
tenantDomains,
updateTenant,
TENANT_MEMBERS,
TENANT_QUOTAS,
type DirectoryTenant,
type TenantMemberKind,
type TenantQuota,
type TenantRoles,
} from "@/lib/adminTenants";
import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/html";
import { t } from "@/lib/i18n";
import { Dialog } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { usePermissions } from "./usePermissions";
const GIB = 1024 ** 3;
interface Props {
/** Null to create one. */
tenant: DirectoryTenant | null;
roles: ReadonlyMap<string, RoleDef> | null;
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/** The label for each quota, and for each kind of thing a tenant holds. */
function quotaLabel(q: TenantQuota): string {
switch (q) {
case "maxAccounts": return t("Accounts");
case "maxGroups": return t("Groups");
case "maxMailingLists": return t("Mailing lists");
case "maxDomains": return t("Domains");
case "maxRoles": return t("Roles");
case "maxDkimKeys": return t("DKIM keys");
case "maxDiskQuota": return t("Storage in GB");
}
}
const roleKey = (roles: TenantRoles | undefined) => (!roles || roles["@type"] === "Default" ? "Default" : `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`);
const rolesFromKey = (key: string): TenantRoles =>
key.startsWith("custom:") ? { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) } : { "@type": "Default" };
/** A quota as the field shows it: GB for disk space, a whole number for the rest, empty for no limit. */
const fieldOf = (q: TenantQuota, v: number | undefined) => (v == null ? "" : q === "maxDiskQuota" ? String(Math.round((v / GIB) * 10) / 10) : String(v));
const valueOf = (q: TenantQuota, s: string): number | null => {
const n = Number(s.replace(",", "."));
if (!s.trim() || !Number.isFinite(n) || n < 0) return null;
return q === "maxDiskQuota" ? Math.round(n * GIB) : Math.floor(n);
};
/**
* One tenant, opened beside the list.
*
* Name, logo, role and quotas save together. What is in the tenant is shown
* rather than stored on it: counts of each kind, read with a `memberTenantId`
* filter, and its domains, which are added and taken out on the spot because
* each is a change to the domain.
*/
export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDeleted }: Props) {
const perms = usePermissions();
const creating = tenant === null;
const editable = creating ? can(perms, "Tenant", "Create") : can(perms, "Tenant", "Update");
const [name, setName] = useState(tenant?.name ?? "");
const [logo, setLogo] = useState(tenant?.logo ?? "");
const [role, setRole] = useState(roleKey(tenant?.roles));
const [quotas, setQuotas] = useState<Record<TenantQuota, string>>(() => Object.fromEntries(TENANT_QUOTAS.map((q) => [q, fieldOf(q, tenant?.quotas?.[q])])) as Record<TenantQuota, string>);
const [counts, setCounts] = useState<Partial<Record<TenantMemberKind, number>> | null>(null);
const [revision, setRevision] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
if (!tenant) return;
let cancelled = false;
void countTenantMembers(tenant.id).then((c) => !cancelled && setCounts(c));
return () => {
cancelled = true;
};
}, [tenant, revision]);
const roleOptions = useMemo(() => {
const options = [{ value: "Default", label: t("Default tenant roles") }];
for (const r of roles?.values() ?? []) {
if (canGrantRole(perms, r.id, roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
}
if (!options.some((o) => o.value === role)) options.push({ value: role, label: t("Custom role") });
return options;
}, [perms, roles, role]);
const save = async () => {
setBusy(true);
setError(null);
try {
const values = Object.fromEntries(TENANT_QUOTAS.map((q) => [q, valueOf(q, quotas[q])])) as Record<TenantQuota, number | null>;
if (!tenant) {
if (!name.trim()) {
setError(t("A tenant needs a name."));
return;
}
const set = Object.fromEntries(Object.entries(values).filter(([, v]) => v != null)) as Record<string, number>;
const id = await createTenant({ name, logo: logo.trim() || null, roles: rolesFromKey(role), quotas: set });
toast.success(t("Created {name}", { name: name.trim() }));
onCreated(id);
return;
}
const patch: Record<string, unknown> = { ...quotasPatch(tenant.quotas, values) };
if (tenant.name !== name.trim()) patch.name = name.trim();
if ((tenant.logo ?? "") !== logo.trim()) patch.logo = logo.trim() || null;
if (roleKey(tenant.roles) !== role) patch.roles = rolesFromKey(role);
if (!Object.keys(patch).length) {
onClose();
return;
}
await updateTenant(tenant.id, patch);
toast.success(t("Saved {name}", { name: name.trim() }));
onChanged();
} catch (err) {
setError(describeDirectoryError(err, "tenant"));
} finally {
setBusy(false);
}
};
const drawable = drawableLogo(logo.trim());
const logoSrc = drawable?.startsWith("data:") ? drawable : drawable ? proxiedImageUrl(drawable) : null;
const held = counts ? Object.values(counts).reduce((a, b) => a + (b ?? 0), 0) : null;
const countsComplete = counts !== null && TENANT_MEMBERS.every((m) => typeof counts[m.key] === "number");
return (
<aside className="admin-sheet" aria-label={creating ? t("New tenant") : tenant.name}>
<div className="admin-sheet-head">
{logoSrc ? <img className="admin-tenant-logo" src={logoSrc} alt="" /> : null}
<div className="grow">
<h2 className="truncate">{creating ? t("New tenant") : tenant.name}</h2>
{tenant && <div className="hint">{t("{used} used", { used: formatSize(tenant.usedDiskQuota ?? 0) })}</div>}
</div>
<button className="icon-btn" onClick={onClose} aria-label={t("Close")}>
<X size={20} />
</button>
</div>
<div className="admin-sheet-body">
{!creating && !editable && <p className="admin-notice">{t("Your role lets you view tenants but not change them.")}</p>}
<h3>{t("Profile")}</h3>
<div className="field">
<label htmlFor="admin-tenant-name">{t("Name")}</label>
<input id="admin-tenant-name" className="input" value={name} disabled={!editable} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
</div>
{!creating && (
<>
<h3>{t("What it holds")}</h3>
{counts === null ? (
<Spinner />
) : (
<dl className="admin-kv">
{TENANT_MEMBERS.map((m) => {
const limit = tenant.quotas?.[m.quota];
const n = counts[m.key];
return (
<div key={m.key} style={{ display: "contents" }}>
<dt>{quotaLabel(m.quota)}</dt>
<dd>{n == null ? "—" : limit != null ? t("{n} of {limit}", { n, limit }) : n}</dd>
</div>
);
})}
<dt>{t("Storage")}</dt>
<dd>{tenant.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(tenant.usedDiskQuota ?? 0), total: formatSize(tenant.quotas.maxDiskQuota) }) : formatSize(tenant.usedDiskQuota ?? 0)}</dd>
</dl>
)}
<h3>{t("Domains")}</h3>
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
</>
)}
<h3>{t("Limits")}</h3>
<div className="admin-quota-grid">
{TENANT_QUOTAS.map((q) => (
<div key={q} className="field">
<label htmlFor={`admin-tenant-${q}`}>{quotaLabel(q)}</label>
<input id={`admin-tenant-${q}`} className="input" inputMode="decimal" value={quotas[q]} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuotas({ ...quotas, [q]: e.target.value })} />
</div>
))}
</div>
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
{roleOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p className="hint">{t("The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.")}</p>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Tenant", "Destroy") && (
<DeleteTenant
tenant={tenant}
blocked={
!countsComplete
? t("Checking what is still in this tenant…")
: held
? t("It still holds accounts, domains or other things. Move them out first.")
: null
}
onDeleted={onDeleted}
/>
)}
</div>
{editable && (
<div className="admin-sheet-foot">
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>
{creating ? t("Create tenant") : t("Save changes")}
</button>
</div>
)}
</aside>
);
}
function TenantDomains({ tenant, canChange, onChanged }: { tenant: DirectoryTenant; canChange: boolean; onChanged: () => void }) {
const [state, setState] = useState<{ inTenant: Array<{ id: string; name: string }>; unassigned: Array<{ id: string; name: string }> } | null>(null);
const [pick, setPick] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
let cancelled = false;
tenantDomains(tenant.id).then(
(s) => {
if (cancelled) return;
setState(s);
setPick(s.unassigned[0]?.id ?? "");
},
(err) => {
if (cancelled) return;
setState({ inTenant: [], unassigned: [] });
setError(describeDirectoryError(err, "domain"));
},
);
return () => {
cancelled = true;
};
}, [tenant, revision]);
const move = async (domain: { id: string; name: string }, into: boolean) => {
setBusy(true);
setError(null);
try {
await setDomainTenant(domain.id, into ? tenant.id : null);
toast.success(into ? t("Added {domain} to {tenant}", { domain: domain.name, tenant: tenant.name }) : t("Took {domain} out of {tenant}", { domain: domain.name, tenant: tenant.name }));
setRevision((n) => n + 1);
onChanged();
} catch (err) {
setError(describeDirectoryError(err, "domain"));
} finally {
setBusy(false);
}
};
if (!state) return <Spinner />;
return (
<div>
{state.inTenant.length ? (
<ul className="admin-members">
{state.inTenant.map((d) => (
<li key={d.id}>
<Globe size={16} aria-hidden="true" />
<span className="grow truncate notranslate" translate="no">{d.name}</span>
{canChange && (
<button className="icon-btn sm" aria-label={t("Take {domain} out of the tenant", { domain: d.name })} disabled={busy} onClick={() => void move(d, false)}>
<X size={16} />
</button>
)}
</li>
))}
</ul>
) : (
<p className="hint" style={{ marginTop: 0 }}>{t("No domains in this tenant yet")}</p>
)}
{canChange && state.unassigned.length > 0 && (
<div className="row mt-8">
<select className="input grow" aria-label={t("Domain to add")} value={pick} onChange={(e) => setPick(e.target.value)}>
{state.unassigned.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<button className="btn btn-sm" disabled={busy || !pick} onClick={() => { const d = state.unassigned.find((x) => x.id === pick); if (d) void move(d, true); }}>
<Plus size={14} /> {t("Add")}
</button>
</div>
)}
{error && <p className="admin-notice error" role="alert">{error}</p>}
<p className="hint">{t("Only domains in no tenant can be added. The accounts already on a domain stay where they are; move each from its own panel.")}</p>
</div>
);
}
function DeleteTenant({ tenant, blocked, onDeleted }: { tenant: DirectoryTenant; blocked: string | null; onDeleted: () => void }) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("An empty tenant can be deleted.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete tenant…")}
</button>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {name}?", { name: tenant.name })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim() !== tenant.name}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyTenant(tenant.id);
toast.success(t("Deleted {name}", { name: tenant.name }));
setOpen(false);
onDeleted();
} catch (err) {
setError(
err instanceof DomainError && err.type === "objectIsLinked" && err.linked.length
? t("Still holds {things}. Move them out first.", { things: describeLinked(err.linked) })
: describeDirectoryError(err, "tenant"),
);
} finally {
setBusy(false);
}
}}
>
{t("Delete tenant")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("It can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-tenant-delete-confirm">{t("Type {name} to confirm", { name: tenant.name })}</label>
<input id="admin-tenant-delete-confirm" className="input" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { Building2, ChevronLeft, ChevronRight, Plus, Search } from "lucide-react";
import { can, type RoleDef } from "@/lib/adminAccess";
import { describeDirectoryError, listRoles } from "@/lib/adminDirectory";
import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/lib/adminTenants";
import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/html";
import { plural, t } from "@/lib/i18n";
import { useSession } from "@/store/session";
import { Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { TenantSheet } from "./TenantSheet";
const PAGE_SIZE = 50;
/**
* Tenants: separate organisations on one server, each with its own people,
* domains and limits.
*
* Shown to whoever may read them, whatever the edition says -- the edition is a
* licence claim, not an authority -- but on a server that does not report
* Enterprise the page says what that means for the people inside one.
*/
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const [page, setPage] = useState<{ tenants: DirectoryTenant[]; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
const [loose, setLoose] = useState<DirectoryTenant | null>(null);
useEffect(() => {
const id = window.setTimeout(() => {
setQuery(text);
setPosition(0);
}, 250);
return () => window.clearTimeout(id);
}, [text]);
useEffect(() => {
let cancelled = false;
setError(null);
void (async () => {
try {
const q = await queryTenants({ text: query, position, limit: PAGE_SIZE });
const tenants = await getTenants(q.ids);
if (!cancelled) setPage({ tenants, total: q.total });
} catch (err) {
if (!cancelled) {
setPage({ tenants: [], total: 0 });
setError(describeDirectoryError(err, "tenant"));
}
}
})();
return () => {
cancelled = true;
};
}, [query, position, reload]);
useEffect(() => {
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
}, [perms]);
useEffect(() => {
if (!selectedId || selectedId === "new" || page?.tenants.some((x) => x.id === selectedId)) {
setLoose(null);
return;
}
let cancelled = false;
void getTenants([selectedId]).then(
([x]) => { if (!cancelled) setLoose(x ?? null); },
() => { if (!cancelled) setLoose(null); },
);
return () => {
cancelled = true;
};
}, [selectedId, page]);
const selected = selectedId && selectedId !== "new" ? (page?.tenants.find((x) => x.id === selectedId) ?? loose) : null;
const close = () => navigate("/admin/tenants");
const changed = () => setReload((n) => n + 1);
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Tenants")}</h1>
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
</div>
{can(perms, "Tenant", "Create") && (
<button className="btn btn-primary" onClick={() => navigate("/admin/tenants/new")}>
<Plus size={16} /> {t("New tenant")}
</button>
)}
</div>
{edition !== "enterprise" && (
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}</p>
)}
<div className="admin-toolbar">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search tenants")} aria-label={t("Search tenants")} />
</label>
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
{page === null ? (
<Spinner />
) : page.tenants.length === 0 ? (
!error && <Empty icon={<Building2 size={32} />} title={query ? t("No tenants match") : t("No tenants yet")} />
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>{t("Tenant")}</th>
<th>{t("Storage")}</th>
<th className="hide-mobile">{t("Account limit")}</th>
</tr>
</thead>
<tbody>
{page.tenants.map((x) => {
const logo = drawableLogo(x.logo);
const src = logo?.startsWith("data:") ? logo : logo ? proxiedImageUrl(logo) : null;
return (
<tr
key={x.id}
className={x.id === selectedId ? "selected" : ""}
tabIndex={0}
onClick={() => navigate(`/admin/tenants/${x.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/admin/tenants/${x.id}`);
}
}}
aria-label={t("Open {name}", { name: x.name })}
>
<td>
<div className="admin-who">
{src ? <img className="admin-tenant-logo sm" src={src} alt="" /> : <Building2 size={20} className="muted" aria-hidden="true" />}
<div className="admin-who-name truncate">{x.name}</div>
</div>
</td>
<td className="muted">
{x.quotas?.maxDiskQuota ? t("{used} of {total}", { used: formatSize(x.usedDiskQuota ?? 0), total: formatSize(x.quotas.maxDiskQuota) }) : t("{used} · no limit", { used: formatSize(x.usedDiskQuota ?? 0) })}
</td>
<td className="hide-mobile muted" style={{ fontVariantNumeric: "tabular-nums" }}>{x.quotas?.maxAccounts ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{page.total <= PAGE_SIZE && position === 0 ? (
<p className="hint admin-count">{plural(page.total, { one: "{n} tenant", other: "{n} tenants" })}</p>
) : (
<div className="admin-pager">
<span className="hint">{t("{from}{to} of {total}", { from: position + 1, to: position + page.tenants.length, total: page.total })}</span>
<button className="icon-btn sm" aria-label={t("Previous page")} disabled={position === 0} onClick={() => setPosition(Math.max(0, position - PAGE_SIZE))}><ChevronLeft size={18} /></button>
<button className="icon-btn sm" aria-label={t("Next page")} disabled={position + page.tenants.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
</div>
)}
</>
)}
{(selectedId === "new" || selected) && (
<TenantSheet
key={selectedId}
tenant={selectedId === "new" ? null : selected!}
roles={roles}
onClose={close}
onChanged={changed}
onCreated={(id) => {
changed();
navigate(`/admin/tenants/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,94 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { DirectoryTenant } from "@/lib/adminTenants";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({
counts: { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 } as Record<string, number>,
updateTenant: vi.fn(async () => {}),
setDomainTenant: vi.fn(async () => {}),
}));
vi.mock("@/lib/adminTenants", async (original) => ({
...(await original<typeof import("@/lib/adminTenants")>()),
countTenantMembers: vi.fn(async () => api.counts),
tenantDomains: vi.fn(async () => ({ inTenant: [{ id: "d3", name: "old-brand.example" }], unassigned: [{ id: "d4", name: "spare.example" }] })),
updateTenant: api.updateTenant,
setDomainTenant: api.setDomainTenant,
}));
const { TenantSheet } = await import("../TenantSheet");
const tenant: DirectoryTenant = { id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, quotas: { maxAccounts: 25, maxDomains: 2, maxOauthClients: 3 }, usedDiskQuota: 0 };
const ALL = ["sysTenantGet", "sysTenantQuery", "sysTenantUpdate", "sysTenantDestroy", "sysDomainUpdate"];
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === label || b.textContent?.trim() === label || b.textContent?.includes(label));
const type = async (el: HTMLInputElement, value: string) => {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
});
};
describe("the tenant sheet", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
await act(async () => {
root.render(<TenantSheet tenant={tenant} roles={new Map()} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
});
await act(async () => {});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.updateTenant.mockClear();
api.setDomainTenant.mockClear();
api.counts = { accounts: 1, groups: 0, lists: 0, domains: 1, roles: 0 };
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows what it holds against its limits, and will not delete while it holds anything", async () => {
signIn(ALL);
await render();
expect(host.querySelector(".admin-kv")?.textContent).toContain("1 of 25");
expect(button(host, "Delete tenant…")?.disabled).toBe(true);
});
it("offers the delete once it is empty", async () => {
api.counts = { accounts: 0, groups: 0, lists: 0, domains: 0, roles: 0 };
signIn(ALL);
await render();
expect(button(host, "Delete tenant…")?.disabled).toBe(false);
});
it("saves a changed limit as one pointer, and an emptied one as no limit", async () => {
signIn(ALL);
await render();
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxAccounts")!, "30");
await type(host.querySelector<HTMLInputElement>("#admin-tenant-maxDomains")!, "");
await act(async () => button(host, "Save changes")!.click());
expect(api.updateTenant).toHaveBeenCalledWith("t1", { "quotas/maxAccounts": 30, "quotas/maxDomains": null });
});
it("moves a domain in, and offers no domain moves without the permission to change domains", async () => {
signIn(ALL);
await render();
await act(async () => button(host, "Add")!.click());
expect(api.setDomainTenant).toHaveBeenCalledWith("d4", "t1");
await act(async () => root.unmount());
root = createRoot(host);
signIn(["sysTenantGet", "sysTenantQuery"]);
await render();
expect(host.querySelector('select[aria-label="Domain to add"]')).toBeNull();
expect(button(host, "Take old-brand.example out of the tenant")).toBeUndefined();
});
});
+2
View File
@@ -8,6 +8,8 @@ export interface DirectoryContext {
/** Null when the viewer cannot read roles, which `outranks` treats as unknown. */
roles: Map<string, RoleDef> | null;
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. */
self: { ids: Set<string>; address: string };
}