Merge pull request #361 from Coffey-Labs/feat/admin-groups
Add Groups to Administration
This commit is contained in:
+29
-2
@@ -1182,6 +1182,33 @@ in as it. ihasmail shows any account that outranks the viewer read-only, and
|
||||
counts a role it cannot read as outranking rather than not. Nobody can change
|
||||
their own role or delete the account they are signed in with.
|
||||
|
||||
## Groups
|
||||
|
||||
A group is a shared address and mailbox, and the people who share it. To
|
||||
Stalwart it is an account whose type is Group, so it takes the same
|
||||
permissions as Accounts (`sysAccountQuery`, `sysAccountGet`) and sits beside
|
||||
it in the menu.
|
||||
|
||||
- **List and search** by name or address, with each group's member count.
|
||||
- **Create** a group on a domain, with a display name, a role and a storage
|
||||
limit; **edit** those and its other addresses, which save together.
|
||||
- **Members** are added by searching for a person and removed one at a time,
|
||||
and each change applies straight away. Stalwart keeps a membership on the
|
||||
member rather than the group, so every change is a one-line update to that
|
||||
person's account that leaves their other groups alone. Nobody can add or
|
||||
remove themselves.
|
||||
- **What a group gives its members** is what has been shared with it — its
|
||||
mailbox, a calendar — not its permissions: a person's permissions come from
|
||||
their own role. The group's own role says what the group may do, and only
|
||||
roles whose permissions the viewer holds are offered, as for accounts.
|
||||
- **Delete** asks for the address to be typed. Stalwart keeps anything that
|
||||
something else still names, and every member's account names its groups, so
|
||||
deleting takes the members out first and then deletes the group — the same
|
||||
order a domain's keys go before the domain. A role that cannot change the
|
||||
members' accounts is not offered a delete it could only half finish.
|
||||
|
||||
Groups do not contain groups; Stalwart has no nesting.
|
||||
|
||||
## Domains
|
||||
|
||||
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||
@@ -1240,8 +1267,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
|
||||
the meantime Stalwart refuses what is no longer allowed.
|
||||
|
||||
The dashboard, accounts and domains are the first three sections. Groups,
|
||||
mailing lists, roles and tenants are Stalwart capabilities the same screen is
|
||||
The dashboard, accounts, groups and domains are the sections so far. Mailing
|
||||
lists, roles and tenants are Stalwart capabilities the same screen is
|
||||
laid out to take. Beyond the dashboard's counts, managing queues, logs and
|
||||
server settings is deliberately out of scope.
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
|
||||
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
|
||||
|
||||
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway `[email protected]`, created and removed, its only member the administrator's own account:
|
||||
|
||||
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
|
||||
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
|
||||
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
|
||||
|
||||
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
|
||||
|
||||
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
||||
|
||||
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
|
||||
|
||||
@@ -83,7 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||
- **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; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **Administration beyond accounts and domains.** The Administration menu opens on a dashboard and manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Administration beyond accounts, groups and domains.** The Administration menu opens on a dashboard and manages accounts, groups and domains today — see [FEATURES.md](FEATURES.md#administration). Mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
||||
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
|
||||
|
||||
@@ -147,3 +147,46 @@ test("helpdesk may count domains, which is what the demo's helpdesk may do", ()
|
||||
assert.ok(permissionsFor("helpdesk").includes("sysDomainQuery"));
|
||||
assert.ok(!permissionsFor("helpdesk").includes("sysMetricQuery"));
|
||||
});
|
||||
|
||||
/** Groups: accounts of type Group, whose members carry the membership. */
|
||||
test("a group's members are the users whose memberships name it", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberGroupIds: "g2" }, calculateTotal: true }) as { ids: string[]; total: number };
|
||||
assert.ok(r.total >= 2);
|
||||
const { list } = dir.handlers["x:Account/get"]!({ ids: r.ids, properties: ["memberGroupIds"] }) as { list: Array<{ memberGroupIds: Record<string, boolean> }> };
|
||||
assert.ok(list.every((a) => a.memberGroupIds.g2));
|
||||
});
|
||||
|
||||
test("a membership pointer moves only that membership, and a group cannot join one", () => {
|
||||
const dir = make("admin");
|
||||
const [ada] = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", text: "lovelace" } }) as { ids: string[] }).ids;
|
||||
dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/g1": true } } });
|
||||
const read = () => ((dir.handlers["x:Account/get"]!({ ids: [ada], properties: ["memberGroupIds"] }) as { list: Array<{ memberGroupIds: Record<string, boolean> }> }).list[0]!.memberGroupIds);
|
||||
assert.deepEqual(Object.keys(read()).sort(), ["g1", "g2"]);
|
||||
dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/g2": null } } });
|
||||
assert.deepEqual(Object.keys(read()), ["g1"]);
|
||||
const nested = dir.handlers["x:Account/set"]!({ update: { g1: { "memberGroupIds/g2": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(nested.notUpdated?.g1?.type, "invalidProperties");
|
||||
const bogus = dir.handlers["x:Account/set"]!({ update: { [ada!]: { "memberGroupIds/u1": true } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||
assert.equal(bogus.notUpdated?.[ada!]?.type, "invalidForeignKey");
|
||||
});
|
||||
|
||||
test("a group is kept while members name it, and goes once they are out", () => {
|
||||
const dir = make("admin");
|
||||
const refused = dir.handlers["x:Account/set"]!({ destroy: ["g2"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||
assert.equal(refused.notDestroyed?.g2?.type, "objectIsLinked");
|
||||
assert.ok(refused.notDestroyed!.g2!.linkedObjects.every((l) => l.object === "Account"));
|
||||
const members = (dir.handlers["x:Account/query"]!({ filter: { "@type": "User", memberGroupIds: "g2" } }) as { ids: string[] }).ids;
|
||||
dir.handlers["x:Account/set"]!({ update: Object.fromEntries(members.map((id) => [id, { "memberGroupIds/g2": null }])) });
|
||||
const done = dir.handlers["x:Account/set"]!({ destroy: ["g2"] }) as { destroyed: string[] };
|
||||
assert.deepEqual(done.destroyed, ["g2"]);
|
||||
});
|
||||
|
||||
test("a group is created without a password, with Default roles", () => {
|
||||
const dir = make("admin");
|
||||
const r = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "Group", name: "sales", domainId: "d1", roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, aliases: {} } } }) as { created: Record<string, { id: string }> };
|
||||
const id = r.created.n!.id;
|
||||
const { list } = dir.handlers["x:Account/get"]!({ ids: [id] }) as { list: Array<Record<string, unknown>> };
|
||||
assert.equal(list[0]!["@type"], "Group");
|
||||
assert.ok(!("memberGroupIds" in list[0]!));
|
||||
});
|
||||
|
||||
@@ -164,8 +164,9 @@ export function createDirectory(opts: Options) {
|
||||
accounts.push(row);
|
||||
return row;
|
||||
};
|
||||
// A group's roles are Default or Custom, not a person's User or Admin.
|
||||
const group = (id: string, name: string, description: string) =>
|
||||
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "User" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {} });
|
||||
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {}, createdAt: "2026-08-01T09:00:00Z" });
|
||||
|
||||
group("g1", "support", "Support");
|
||||
group("g2", "office", "Office");
|
||||
@@ -301,7 +302,8 @@ export function createDirectory(opts: Options) {
|
||||
const handlers: Record<string, (a: Obj) => Obj> = {
|
||||
"x:Account/get": get(accounts, "sysAccountGet"),
|
||||
"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) && matchText(o, f.text) && matchText(o, f.name)),
|
||||
(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)),
|
||||
"x:Account/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const notCreated: Obj = {};
|
||||
@@ -321,7 +323,7 @@ export function createDirectory(opts: Options) {
|
||||
const weak = password ? weakPassword(password.secret) : null;
|
||||
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
||||
const id = `u${counter++}`;
|
||||
accounts.push({ memberGroupIds: {}, aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
|
||||
accounts.push({ ...(o["@type"] === "Group" ? {} : { memberGroupIds: {} }), aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
|
||||
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||
}
|
||||
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||
@@ -343,6 +345,11 @@ export function createDirectory(opts: Options) {
|
||||
}
|
||||
setPointer(next, path, value);
|
||||
}
|
||||
// Memberships name groups, and only a person has them: groups do not nest.
|
||||
if (!failure && Object.keys(patch).some((p) => p === "memberGroupIds" || p.startsWith("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"]);
|
||||
}
|
||||
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
||||
const refused = grantRefused(next.roles);
|
||||
if (refused) failure = setError("forbidden", refused);
|
||||
@@ -365,7 +372,10 @@ export function createDirectory(opts: Options) {
|
||||
const i = accounts.findIndex((x) => x.id === id);
|
||||
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
|
||||
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
|
||||
notDestroyed[id] = { ...setError("objectIsLinked", "Group still has members."), linkedObjects: {} };
|
||||
// Every member's memberGroupIds names the group, which is a link the
|
||||
// registry will not delete through. The shape is the live server's,
|
||||
// from a throwaway group on 2026-09-15.
|
||||
notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Account", id }, linkedObjects: accounts.filter((x) => (x.memberGroupIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })) };
|
||||
continue;
|
||||
}
|
||||
accounts.splice(i, 1);
|
||||
|
||||
@@ -13,7 +13,8 @@ const roles = new Map<string, RoleDef>([
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list to list accounts", () => {
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts"]);
|
||||
// Groups are accounts to the server, so they come with the same two permissions.
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts", "groups"]);
|
||||
// A query alone is a count on the dashboard, not a list.
|
||||
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
@@ -23,7 +24,7 @@ describe("who is offered administration", () => {
|
||||
it("offers each section only with both halves of reading it", () => {
|
||||
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
|
||||
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts"]);
|
||||
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts", "groups"]);
|
||||
});
|
||||
|
||||
it("gives the dashboard a card for each number the role can read", () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
|
||||
|
||||
describe("group membership", () => {
|
||||
it("is a patch to each member, one pointer each, so no other membership moves", () => {
|
||||
// Stalwart's set patch adds a key on `true` and removes it on `null`, and
|
||||
// leaves every other key in the set as it was.
|
||||
expect(membershipPatch(["u1", "u2"], "g1", true)).toEqual({ u1: { "memberGroupIds/g1": true }, u2: { "memberGroupIds/g1": true } });
|
||||
expect(membershipPatch(["u1"], "g1", false)).toEqual({ u1: { "memberGroupIds/g1": null } });
|
||||
});
|
||||
|
||||
it("counts members as users whose memberships name the group, asking for no ids", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 4 });
|
||||
expect(await countMembers(["g1"])).toEqual(new Map([["g1", 4]]));
|
||||
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberGroupIds: "g1" }, limit: 0, calculateTotal: true });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("leaves a count out rather than showing a failed one as none", async () => {
|
||||
const call = vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
|
||||
expect(await countMembers(["g1"])).toEqual(new Map());
|
||||
call.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating and deleting a group", () => {
|
||||
it("creates an account of type Group, with nothing a person needs to sign in", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "g9" } } });
|
||||
expect(await createGroup({ name: " sales ", domainId: "d1", description: "", roles: { "@type": "Default" }, diskQuotaBytes: null })).toBe("g9");
|
||||
const create = (call.mock.calls[0]![1] as { create: { n: Record<string, unknown> } }).create.n;
|
||||
expect(create).toMatchObject({ "@type": "Group", name: "sales", domainId: "d1", description: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {} });
|
||||
expect(create).not.toHaveProperty("credentials");
|
||||
expect(create).not.toHaveProperty("encryptionAtRest");
|
||||
expect(create).not.toHaveProperty("memberGroupIds");
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("takes the members out before deleting, and deletes nothing if that fails", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValueOnce({ updated: { u1: null } }).mockResolvedValueOnce({ destroyed: ["g1"] });
|
||||
await destroyGroup("g1", ["u1"]);
|
||||
expect(call.mock.calls.map((c) => [c[0], Object.keys(c[1] as object)])).toEqual([
|
||||
["x:Account/set", ["update"]],
|
||||
["x:Account/set", ["destroy"]],
|
||||
]);
|
||||
call.mockReset();
|
||||
call.mockResolvedValueOnce({ notUpdated: { u1: { type: "forbidden" } } });
|
||||
await expect(destroyGroup("g1", ["u1"])).rejects.toMatchObject({ type: "forbidden" });
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("goes straight to the delete for a group with no members", async () => {
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ destroyed: ["g1"] });
|
||||
await destroyGroup("g1", []);
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
expect(call).toHaveBeenCalledWith("x:Account/set", { destroy: ["g1"] });
|
||||
call.mockRestore();
|
||||
});
|
||||
|
||||
it("round-trips a group's roles, which are Default or Custom", () => {
|
||||
for (const roles of [{ "@type": "Default" } as const, { "@type": "Custom", roleIds: { r1: true, r2: true } } as const]) {
|
||||
expect(groupRolesFromKey(groupRoleKey(roles))).toEqual(roles);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" | "domains";
|
||||
export type AdminSection = "dashboard" | "accounts" | "groups" | "domains";
|
||||
|
||||
export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent";
|
||||
|
||||
@@ -60,7 +60,8 @@ export function dashboardCards(perms: Permissions): DashboardCard[] {
|
||||
export function adminSections(perms: Permissions): AdminSection[] {
|
||||
const out: AdminSection[] = [];
|
||||
if (dashboardCards(perms).length) out.push("dashboard");
|
||||
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts");
|
||||
// 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, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,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";
|
||||
export type DirectoryObject = "account" | "domain" | "group";
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action, in their language.
|
||||
@@ -272,11 +272,19 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
|
||||
case "invalidForeignKey":
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return object === "domain" ? t("Your organisation has reached the number of domains it is allowed.") : t("Your organisation has reached the number of accounts it is allowed.");
|
||||
return object === "domain"
|
||||
? t("Your organisation has reached the number of domains it is allowed.")
|
||||
: object === "group"
|
||||
? t("Your organisation has reached the number of groups it is allowed.")
|
||||
: t("Your organisation has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
return object === "domain" ? t("This domain no longer exists. Someone may have removed it.") : t("This account no longer exists. Someone may have deleted it.");
|
||||
return object === "domain"
|
||||
? t("This domain no longer exists. Someone may have removed it.")
|
||||
: object === "group"
|
||||
? t("This group 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":
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, UserRoles } from "@/lib/adminAccess";
|
||||
import { DirectoryError, DISK_QUOTA, queryAccounts, type EmailAlias } from "@/lib/adminDirectory";
|
||||
|
||||
/**
|
||||
* Groups, from Stalwart 0.16's directory.
|
||||
*
|
||||
* A group is not an object of its own: it is an `x:Account` whose `@type` is
|
||||
* `Group`, read and written with the same methods and the same `sysAccount*`
|
||||
* permissions as a person. What differs, from the 0.16.22 source:
|
||||
*
|
||||
* - **Membership lives on the member.** A group has no list of members; each
|
||||
* user carries `memberGroupIds`, and a group's members are the users whose set
|
||||
* names it. Adding or removing one is a patch to that user --
|
||||
* `memberGroupIds/<group>: true`, or `null` to take it out -- which touches
|
||||
* nothing else in the set. Groups do not nest: a group has no memberships.
|
||||
* - **Membership is access, not permission.** A user's permissions come from
|
||||
* their own roles only. What a group gives its members is whatever has been
|
||||
* shared with the group -- a mailbox, a calendar.
|
||||
* - **Roles are `Default` or `Custom`,** not a person's `User`/`Admin`/`Custom`.
|
||||
* A group has no credentials and cannot sign in.
|
||||
*/
|
||||
|
||||
export type GroupRoles = { "@type": "Default" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export interface DirectoryGroup {
|
||||
id: string;
|
||||
"@type": "Group";
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: GroupRoles;
|
||||
permissions?: PermissionsMode;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/** A member as the group's panel shows them, with what `outranks` and `isSelf` need. */
|
||||
export interface GroupMember {
|
||||
id: string;
|
||||
name: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: UserRoles;
|
||||
permissions?: PermissionsMode;
|
||||
}
|
||||
|
||||
const GROUP_PROPERTIES = ["@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas", "usedDiskQuota", "aliases", "createdAt"];
|
||||
const MEMBER_PROPERTIES = ["name", "emailAddress", "description", "roles", "permissions"];
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: 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 DirectoryError(first.type, first.description, first.properties);
|
||||
}
|
||||
|
||||
export const queryGroups = (opts: { text?: string; position?: number; limit?: number }) => queryAccounts({ type: "Group", ...opts });
|
||||
|
||||
export async function getGroups(ids: string[]): Promise<DirectoryGroup[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryGroup[] }>("x:Account/get", { ids, properties: GROUP_PROPERTIES });
|
||||
const byId = new Map(res.list.map((g) => [g.id, g]));
|
||||
return ids.map((id) => byId.get(id)).filter((g): g is DirectoryGroup => Boolean(g));
|
||||
}
|
||||
|
||||
/** The filter that finds a group's members: users whose memberships name it. */
|
||||
export const memberFilter = (groupId: string) => ({ "@type": "User", memberGroupIds: groupId });
|
||||
|
||||
/** How many members each group has. A count that fails is left out rather than shown as none. */
|
||||
export async function countMembers(groupIds: string[]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
await Promise.all(
|
||||
groupIds.map(async (id) => {
|
||||
try {
|
||||
const res = await client.call<{ total?: number }>("x:Account/query", { filter: memberFilter(id), limit: 0, calculateTotal: true });
|
||||
if (typeof res.total === "number") out.set(id, res.total);
|
||||
} catch {
|
||||
/* the column shows a dash */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A group's members, as many as one get allows, newest first as the server orders them. */
|
||||
export async function listMembers(groupId: string): Promise<{ members: GroupMember[]; total: number }> {
|
||||
const q = await client.call<{ ids?: string[]; total?: number }>("x:Account/query", { filter: memberFilter(groupId), limit: client.maxObjectsInGet, calculateTotal: true });
|
||||
const ids = q.ids ?? [];
|
||||
if (!ids.length) return { members: [], total: q.total ?? 0 };
|
||||
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids, properties: MEMBER_PROPERTIES });
|
||||
return { members: res.list, total: q.total ?? res.list.length };
|
||||
}
|
||||
|
||||
/** People to offer when adding a member, by name or address. */
|
||||
export async function searchUsers(text: string, limit = 8): Promise<GroupMember[]> {
|
||||
const q = await queryAccounts({ type: "User", text, limit });
|
||||
if (!q.ids.length) return [];
|
||||
const res = await client.call<{ list: GroupMember[] }>("x:Account/get", { ids: q.ids, properties: MEMBER_PROPERTIES });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export interface NewGroup {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
roles: GroupRoles;
|
||||
diskQuotaBytes: number | null;
|
||||
}
|
||||
|
||||
export async function createGroup(input: NewGroup): Promise<string> {
|
||||
const res = await client.call<SetResponse>("x:Account/set", {
|
||||
create: {
|
||||
n: {
|
||||
"@type": "Group",
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
roles: input.roles,
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||
aliases: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the group was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The patch that puts users into a group or takes them out, one pointer each so no other membership moves. */
|
||||
export function membershipPatch(userIds: readonly string[], groupId: string, member: boolean): Record<string, Record<string, true | null>> {
|
||||
return Object.fromEntries(userIds.map((id) => [id, { [`memberGroupIds/${groupId}`]: member ? true : null }]));
|
||||
}
|
||||
|
||||
export async function setMembership(userIds: readonly string[], groupId: string, member: boolean): Promise<void> {
|
||||
if (!userIds.length) return;
|
||||
const res = await client.call<SetResponse>("x:Account/set", { update: membershipPatch(userIds, groupId, member) });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group, taking its members out of it first.
|
||||
*
|
||||
* Stalwart keeps an object that others still name, and every member's
|
||||
* `memberGroupIds` names the group -- the same reason a domain's keys go
|
||||
* before the domain. The two are separate calls: if the memberships cannot be
|
||||
* changed, nothing has been deleted.
|
||||
*/
|
||||
export async function destroyGroup(groupId: string, memberIds: readonly string[]): Promise<void> {
|
||||
await setMembership(memberIds, groupId, false);
|
||||
const res = await client.call<SetResponse>("x:Account/set", { destroy: [groupId] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/** A group's roles as one select value: "Default", or "custom:<ids>". */
|
||||
export function groupRoleKey(roles: GroupRoles | undefined): string {
|
||||
if (!roles || roles["@type"] === "Default") return "Default";
|
||||
return `custom:${Object.keys(roles.roleIds ?? {}).sort().join(",")}`;
|
||||
}
|
||||
|
||||
export function groupRolesFromKey(key: string): GroupRoles {
|
||||
if (key.startsWith("custom:")) return { "@type": "Custom", roleIds: Object.fromEntries(key.slice(7).split(",").filter(Boolean).map((id) => [id, true])) };
|
||||
return { "@type": "Default" };
|
||||
}
|
||||
@@ -149,6 +149,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Konnte nicht geladen werden",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in der Verwaltung von Stalwart selbst.",
|
||||
"Open Stalwart admin": "Stalwart-Verwaltung öffnen",
|
||||
"Default group role": "Standardrolle für Gruppen",
|
||||
"A group needs an address.": "Eine Gruppe braucht eine Adresse.",
|
||||
"New group": "Neue Gruppe",
|
||||
"Your role lets you view groups but not change them.": "Ihre Rolle erlaubt es, Gruppen anzusehen, aber nicht zu ändern.",
|
||||
"No domains are available to create a group on.": "Es gibt keine Domains, auf denen eine Gruppe angelegt werden kann.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "E-Mails an diese Adressen werden an diese Gruppe zugestellt. Änderungen gelten beim Speichern.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Was die Gruppe selbst tun darf. Mitglieder behalten ihre eigenen Rollen: Eine Gruppe gibt ihnen das, was mit ihr geteilt wird, nicht ihre Berechtigungen. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
|
||||
"Loading the group's members…": "Mitglieder der Gruppe werden geladen…",
|
||||
"This group has more members than can be taken out at once.": "Diese Gruppe hat mehr Mitglieder, als auf einmal entfernt werden können.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Beim Löschen einer Gruppe werden zuerst ihre Mitglieder daraus entfernt, und Ihre Rolle darf deren Konten nicht ändern.",
|
||||
"Create group": "Gruppe anlegen",
|
||||
"Added {address} to the group": "{address} zur Gruppe hinzugefügt",
|
||||
"Removed {address} from the group": "{address} aus der Gruppe entfernt",
|
||||
"Remove {address} from the group": "{address} aus der Gruppe entfernen",
|
||||
"You can't change your own group memberships.": "Sie können Ihre eigenen Gruppenmitgliedschaften nicht ändern.",
|
||||
"Remove from group": "Aus der Gruppe entfernen",
|
||||
"No members yet": "Noch keine Mitglieder",
|
||||
"Showing {shown} of {total} members.": "{shown} von {total} Mitgliedern werden angezeigt.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Mitglieder erhalten, was mit der Gruppe geteilt wird, etwa ihr Postfach. Änderungen gelten sofort.",
|
||||
"Add a member by name or address": "Mitglied nach Name oder Adresse hinzufügen",
|
||||
"Add a member": "Mitglied hinzufügen",
|
||||
"No one else matches": "Sonst passt niemand",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Löscht die Gruppe und ihr Postfach. Die eigenen Konten der Mitglieder bleiben erhalten.",
|
||||
"Delete group…": "Gruppe löschen…",
|
||||
"Delete group": "Gruppe löschen",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "Die E-Mails der Gruppe werden im Hintergrund entfernt, und das lässt sich nicht rückgängig machen.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Gemeinsame Adressen und Postfächer, und die Personen, die sie teilen.",
|
||||
"Search groups": "Gruppen durchsuchen",
|
||||
"No groups match": "Keine passenden Gruppen",
|
||||
"No groups yet": "Noch keine Gruppen",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Ihre Organisation hat die erlaubte Anzahl an Gruppen erreicht.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Diese Gruppe existiert nicht mehr. Jemand hat sie möglicherweise gelöscht.",
|
||||
"The server did not say whether the group was created.": "Der Server hat nicht mitgeteilt, ob die Gruppe angelegt wurde.",
|
||||
"User": "Benutzer",
|
||||
"Administrator": "Administrator",
|
||||
"Custom role": "Eigene Rolle",
|
||||
@@ -1560,6 +1593,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} Konto verwendet diese Domain. Verschieben oder löschen Sie es zuerst.", other: "{n} Konten verwenden diese Domain. Verschieben oder löschen Sie sie zuerst." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihr {n} DKIM-Schlüssel wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihre {n} DKIM-Schlüssel werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
|
||||
"{n} domains": { one: "{n} Domain", other: "{n} Domains" },
|
||||
"{n} groups": { one: "{n} Gruppe", other: "{n} Gruppen" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Sein {n} Mitglied wird zuerst aus der Gruppe entfernt und verliert, was mit ihr geteilt wurde. Die E-Mails der Gruppe werden im Hintergrund entfernt, und das lässt sich nicht rückgängig machen.", other: "Ihre {n} Mitglieder werden zuerst aus der Gruppe entfernt und verlieren, was mit ihr geteilt wurde. Die E-Mails der Gruppe werden im Hintergrund entfernt, und das lässt sich nicht rückgängig machen." },
|
||||
"{n} mailing lists": { one: "{n} Mailingliste", other: "{n} Mailinglisten" },
|
||||
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
|
||||
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
|
||||
|
||||
@@ -141,6 +141,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "No se pudo cargar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Las métricas detalladas, la cola de entrega, los registros y la configuración del servidor están en la administración de Stalwart.",
|
||||
"Open Stalwart admin": "Abrir la administración de Stalwart",
|
||||
"Default group role": "Rol de grupo predeterminado",
|
||||
"A group needs an address.": "Un grupo necesita una dirección.",
|
||||
"New group": "Nuevo grupo",
|
||||
"Your role lets you view groups but not change them.": "Su rol le permite ver los grupos, pero no modificarlos.",
|
||||
"No domains are available to create a group on.": "No hay dominios disponibles para crear un grupo.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "El correo a estas direcciones se entrega a este grupo. Los cambios se aplican al guardar.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Lo que el propio grupo puede hacer. Los miembros conservan sus propios roles: un grupo les da lo que se comparte con él, no sus permisos. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
|
||||
"Loading the group's members…": "Cargando los miembros del grupo…",
|
||||
"This group has more members than can be taken out at once.": "Este grupo tiene más miembros de los que se pueden quitar de una vez.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Eliminar un grupo primero quita a sus miembros, y su rol no puede modificar sus cuentas.",
|
||||
"Create group": "Crear grupo",
|
||||
"Added {address} to the group": "{address} añadido al grupo",
|
||||
"Removed {address} from the group": "{address} quitado del grupo",
|
||||
"Remove {address} from the group": "Quitar {address} del grupo",
|
||||
"You can't change your own group memberships.": "No puede cambiar sus propias pertenencias a grupos.",
|
||||
"Remove from group": "Quitar del grupo",
|
||||
"No members yet": "Aún no hay miembros",
|
||||
"Showing {shown} of {total} members.": "Se muestran {shown} de {total} miembros.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Los miembros reciben lo que se comparte con el grupo, como su buzón. Los cambios se aplican al instante.",
|
||||
"Add a member by name or address": "Añadir un miembro por nombre o dirección",
|
||||
"Add a member": "Añadir un miembro",
|
||||
"No one else matches": "Nadie más coincide",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Elimina el grupo y su buzón. Las cuentas de sus miembros se conservan.",
|
||||
"Delete group…": "Eliminar grupo…",
|
||||
"Delete group": "Eliminar grupo",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "El correo del grupo se elimina en segundo plano, y no se puede deshacer.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Direcciones y buzones compartidos, y las personas que los comparten.",
|
||||
"Search groups": "Buscar grupos",
|
||||
"No groups match": "Ningún grupo coincide",
|
||||
"No groups yet": "Aún no hay grupos",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Su organización ha alcanzado el número de grupos permitido.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Este grupo ya no existe. Puede que alguien lo haya eliminado.",
|
||||
"The server did not say whether the group was created.": "El servidor no indicó si el grupo se creó.",
|
||||
"User": "Usuario",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Rol personalizado",
|
||||
@@ -1533,6 +1566,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} cuenta usa este dominio. Muévala o elimínela primero.", other: "{n} cuentas usan este dominio. Muévalas o elimínelas primero." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "El servidor deja de aceptar correo para este dominio y se elimina su {n} clave DKIM. No se puede deshacer.", other: "El servidor deja de aceptar correo para este dominio y se eliminan sus {n} claves DKIM. No se puede deshacer." },
|
||||
"{n} domains": { one: "{n} dominio", other: "{n} dominios" },
|
||||
"{n} groups": { one: "{n} grupo", other: "{n} grupos" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Primero se quita del grupo a su {n} miembro, que pierde lo que se compartía con él. El correo del grupo se elimina en segundo plano, y no se puede deshacer.", other: "Primero se quita del grupo a sus {n} miembros, que pierden lo que se compartía con él. El correo del grupo se elimina en segundo plano, y no se puede deshacer." },
|
||||
"{n} mailing lists": { one: "{n} lista de correo", other: "{n} listas de correo" },
|
||||
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
|
||||
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
|
||||
|
||||
@@ -146,6 +146,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Chargement impossible",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans l’administration de Stalwart.",
|
||||
"Open Stalwart admin": "Ouvrir l’administration de Stalwart",
|
||||
"Default group role": "Rôle de groupe par défaut",
|
||||
"A group needs an address.": "Un groupe a besoin d’une adresse.",
|
||||
"New group": "Nouveau groupe",
|
||||
"Your role lets you view groups but not change them.": "Votre rôle vous permet de consulter les groupes, mais pas de les modifier.",
|
||||
"No domains are available to create a group on.": "Aucun domaine n’est disponible pour créer un groupe.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "Les messages envoyés à ces adresses sont distribués à ce groupe. Les modifications s’appliquent à l’enregistrement.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Ce que le groupe lui-même peut faire. Les membres gardent leurs propres rôles : un groupe leur donne ce qui est partagé avec lui, pas ses autorisations. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.",
|
||||
"Loading the group's members…": "Chargement des membres du groupe…",
|
||||
"This group has more members than can be taken out at once.": "Ce groupe a plus de membres qu’on ne peut en retirer en une fois.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Supprimer un groupe en retire d’abord les membres, et votre rôle ne permet pas de modifier leurs comptes.",
|
||||
"Create group": "Créer le groupe",
|
||||
"Added {address} to the group": "{address} ajouté au groupe",
|
||||
"Removed {address} from the group": "{address} retiré du groupe",
|
||||
"Remove {address} from the group": "Retirer {address} du groupe",
|
||||
"You can't change your own group memberships.": "Vous ne pouvez pas modifier vos propres appartenances à des groupes.",
|
||||
"Remove from group": "Retirer du groupe",
|
||||
"No members yet": "Aucun membre pour l’instant",
|
||||
"Showing {shown} of {total} members.": "{shown} membres affichés sur {total}.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Les membres reçoivent ce qui est partagé avec le groupe, comme sa boîte aux lettres. Les modifications s’appliquent immédiatement.",
|
||||
"Add a member by name or address": "Ajouter un membre par nom ou adresse",
|
||||
"Add a member": "Ajouter un membre",
|
||||
"No one else matches": "Personne d’autre ne correspond",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Supprime le groupe et sa boîte aux lettres. Les comptes des membres sont conservés.",
|
||||
"Delete group…": "Supprimer le groupe…",
|
||||
"Delete group": "Supprimer le groupe",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "Les messages du groupe sont supprimés en arrière-plan, et c’est irréversible.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Adresses et boîtes aux lettres partagées, et les personnes qui les partagent.",
|
||||
"Search groups": "Rechercher des groupes",
|
||||
"No groups match": "Aucun groupe ne correspond",
|
||||
"No groups yet": "Aucun groupe pour l’instant",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Votre organisation a atteint le nombre de groupes autorisé.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Ce groupe n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||
"The server did not say whether the group was created.": "Le serveur n’a pas indiqué si le groupe a été créé.",
|
||||
"User": "Utilisateur",
|
||||
"Administrator": "Administrateur",
|
||||
"Custom role": "Rôle personnalisé",
|
||||
@@ -1538,6 +1571,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} compte utilise ce domaine. Déplacez-le ou supprimez-le d’abord.", other: "{n} comptes utilisent ce domaine. Déplacez-les ou supprimez-les d’abord." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Le serveur n’accepte plus de messages pour ce domaine, et sa {n} clé DKIM est supprimée. C’est irréversible.", other: "Le serveur n’accepte plus de messages pour ce domaine, et ses {n} clés DKIM sont supprimées. C’est irréversible." },
|
||||
"{n} domains": { one: "{n} domaine", other: "{n} domaines" },
|
||||
"{n} groups": { one: "{n} groupe", other: "{n} groupes" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Son {n} membre est d’abord retiré du groupe et perd ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et c’est irréversible.", other: "Ses {n} membres sont d’abord retirés du groupe et perdent ce qui était partagé avec lui. Les messages du groupe sont supprimés en arrière-plan, et c’est irréversible." },
|
||||
"{n} mailing lists": { one: "{n} liste de diffusion", other: "{n} listes de diffusion" },
|
||||
"{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" },
|
||||
|
||||
@@ -140,6 +140,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "読み込めませんでした",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "詳しいメトリクス、配送キュー、ログ、サーバー設定は Stalwart 自体の管理画面にあります。",
|
||||
"Open Stalwart admin": "Stalwart の管理画面を開く",
|
||||
"Default group role": "グループの既定ロール",
|
||||
"A group needs an address.": "グループにはアドレスが必要です。",
|
||||
"New group": "新しいグループ",
|
||||
"Your role lets you view groups but not change them.": "あなたのロールでは、グループの閲覧はできますが変更はできません。",
|
||||
"No domains are available to create a group on.": "グループを作成できるドメインがありません。",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "これらのアドレス宛てのメールはこのグループに配信されます。変更は保存時に反映されます。",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "グループ自体ができること。メンバーは自分のロールを保ちます。グループがメンバーに与えるのはグループと共有されたものであり、グループの権限ではありません。表示されるのは、あなた自身が権限を持つロールだけです。",
|
||||
"Loading the group's members…": "グループのメンバーを読み込んでいます…",
|
||||
"This group has more members than can be taken out at once.": "このグループは、一度に外せる数より多くのメンバーがいます。",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "グループを削除するとまずメンバーが外されますが、あなたのロールではメンバーのアカウントを変更できません。",
|
||||
"Create group": "グループを作成",
|
||||
"Added {address} to the group": "{address} をグループに追加しました",
|
||||
"Removed {address} from the group": "{address} をグループから外しました",
|
||||
"Remove {address} from the group": "{address} をグループから外す",
|
||||
"You can't change your own group memberships.": "自分のグループ所属は変更できません。",
|
||||
"Remove from group": "グループから外す",
|
||||
"No members yet": "まだメンバーがいません",
|
||||
"Showing {shown} of {total} members.": "{total} 人中 {shown} 人のメンバーを表示しています。",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "メンバーは、グループのメールボックスなど、グループと共有されたものを使えます。変更はすぐに反映されます。",
|
||||
"Add a member by name or address": "名前またはアドレスでメンバーを追加",
|
||||
"Add a member": "メンバーを追加",
|
||||
"No one else matches": "ほかに該当する人はいません",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "グループとそのメールボックスを削除します。メンバー自身のアカウントは残ります。",
|
||||
"Delete group…": "グループを削除…",
|
||||
"Delete group": "グループを削除",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "グループのメールはバックグラウンドで削除され、元に戻せません。",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "共有のアドレスとメールボックス、そしてそれを共有する人たち。",
|
||||
"Search groups": "グループを検索",
|
||||
"No groups match": "一致するグループはありません",
|
||||
"No groups yet": "まだグループがありません",
|
||||
"Your organisation has reached the number of groups it is allowed.": "組織で許可されているグループ数の上限に達しました。",
|
||||
"This group no longer exists. Someone may have deleted it.": "このグループはもう存在しません。誰かが削除した可能性があります。",
|
||||
"The server did not say whether the group was created.": "グループが作成されたかどうか、サーバーから返答がありませんでした。",
|
||||
"User": "ユーザー",
|
||||
"Administrator": "管理者",
|
||||
"Custom role": "カスタムロール",
|
||||
@@ -1541,6 +1574,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { other: "{n} 件のアカウントがこのドメインを使用しています。先に移動または削除してください。" },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "サーバーはこのドメイン宛てのメールを受け付けなくなり、{n} 個の DKIM 鍵も削除されます。元に戻すことはできません。" },
|
||||
"{n} domains": { other: "{n} 件のドメイン" },
|
||||
"{n} groups": { other: "{n} 件のグループ" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { other: "まず {n} 人のメンバーがグループから外され、グループと共有されていたものを使えなくなります。グループのメールはバックグラウンドで削除され、元に戻せません。" },
|
||||
"{n} mailing lists": { other: "{n} 件のメーリングリスト" },
|
||||
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
|
||||
"{n} other items": { other: "その他 {n} 件" },
|
||||
|
||||
@@ -137,6 +137,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Kon niet worden geladen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in het eigen beheer van Stalwart.",
|
||||
"Open Stalwart admin": "Stalwart-beheer openen",
|
||||
"Default group role": "Standaardrol voor groepen",
|
||||
"A group needs an address.": "Een groep heeft een adres nodig.",
|
||||
"New group": "Nieuwe groep",
|
||||
"Your role lets you view groups but not change them.": "Met uw rol kunt u groepen bekijken, maar niet wijzigen.",
|
||||
"No domains are available to create a group on.": "Er zijn geen domeinen waarop een groep kan worden aangemaakt.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "E-mail aan deze adressen wordt bij deze groep bezorgd. Wijzigingen gelden zodra u opslaat.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Wat de groep zelf mag doen. Leden houden hun eigen rollen: een groep geeft hen wat ermee gedeeld is, niet haar rechten. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
|
||||
"Loading the group's members…": "Leden van de groep laden…",
|
||||
"This group has more members than can be taken out at once.": "Deze groep heeft meer leden dan er in één keer uit kunnen worden gehaald.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Een groep verwijderen haalt eerst de leden eruit, en met uw rol kunt u hun accounts niet wijzigen.",
|
||||
"Create group": "Groep aanmaken",
|
||||
"Added {address} to the group": "{address} aan de groep toegevoegd",
|
||||
"Removed {address} from the group": "{address} uit de groep gehaald",
|
||||
"Remove {address} from the group": "{address} uit de groep halen",
|
||||
"You can't change your own group memberships.": "U kunt uw eigen groepslidmaatschappen niet wijzigen.",
|
||||
"Remove from group": "Uit de groep halen",
|
||||
"No members yet": "Nog geen leden",
|
||||
"Showing {shown} of {total} members.": "{shown} van {total} leden getoond.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Leden krijgen wat met de groep gedeeld is, zoals haar postvak. Wijzigingen gelden meteen.",
|
||||
"Add a member by name or address": "Lid toevoegen op naam of adres",
|
||||
"Add a member": "Lid toevoegen",
|
||||
"No one else matches": "Verder komt niemand overeen",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Verwijdert de groep en haar postvak. De eigen accounts van de leden blijven.",
|
||||
"Delete group…": "Groep verwijderen…",
|
||||
"Delete group": "Groep verwijderen",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Gedeelde adressen en postvakken, en de mensen die ze delen.",
|
||||
"Search groups": "Groepen zoeken",
|
||||
"No groups match": "Geen groepen gevonden",
|
||||
"No groups yet": "Nog geen groepen",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Uw organisatie heeft het toegestane aantal groepen bereikt.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Deze groep bestaat niet meer. Iemand heeft hem mogelijk verwijderd.",
|
||||
"The server did not say whether the group was created.": "De server heeft niet gemeld of de groep is aangemaakt.",
|
||||
"User": "Gebruiker",
|
||||
"Administrator": "Beheerder",
|
||||
"Custom role": "Aangepaste rol",
|
||||
@@ -1529,6 +1562,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder het eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
|
||||
"{n} domains": { one: "{n} domein", other: "{n} domeinen" },
|
||||
"{n} groups": { one: "{n} groep", other: "{n} groepen" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." },
|
||||
"{n} mailing lists": { one: "{n} mailinglijst", other: "{n} mailinglijsten" },
|
||||
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
||||
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
||||
|
||||
@@ -144,6 +144,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Não foi possível carregar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Métricas detalhadas, a fila de entrega, os logs e as configurações do servidor ficam na administração do próprio Stalwart.",
|
||||
"Open Stalwart admin": "Abrir a administração do Stalwart",
|
||||
"Default group role": "Função padrão de grupo",
|
||||
"A group needs an address.": "Um grupo precisa de um endereço.",
|
||||
"New group": "Novo grupo",
|
||||
"Your role lets you view groups but not change them.": "Sua função permite ver os grupos, mas não alterá-los.",
|
||||
"No domains are available to create a group on.": "Não há domínios disponíveis para criar um grupo.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "Os e-mails para estes endereços são entregues a este grupo. As alterações valem ao salvar.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "O que o próprio grupo pode fazer. Os membros mantêm suas próprias funções: um grupo lhes dá o que é compartilhado com ele, não suas permissões. Só são oferecidas funções cujas permissões você mesmo tem.",
|
||||
"Loading the group's members…": "Carregando os membros do grupo…",
|
||||
"This group has more members than can be taken out at once.": "Este grupo tem mais membros do que é possível retirar de uma vez.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Excluir um grupo retira primeiro os membros dele, e sua função não pode alterar as contas deles.",
|
||||
"Create group": "Criar grupo",
|
||||
"Added {address} to the group": "{address} adicionado ao grupo",
|
||||
"Removed {address} from the group": "{address} retirado do grupo",
|
||||
"Remove {address} from the group": "Retirar {address} do grupo",
|
||||
"You can't change your own group memberships.": "Você não pode alterar suas próprias participações em grupos.",
|
||||
"Remove from group": "Retirar do grupo",
|
||||
"No members yet": "Nenhum membro ainda",
|
||||
"Showing {shown} of {total} members.": "Mostrando {shown} de {total} membros.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Os membros recebem o que é compartilhado com o grupo, como a caixa de correio dele. As alterações valem na hora.",
|
||||
"Add a member by name or address": "Adicionar um membro por nome ou endereço",
|
||||
"Add a member": "Adicionar um membro",
|
||||
"No one else matches": "Mais ninguém corresponde",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Exclui o grupo e a caixa de correio dele. As contas dos membros continuam.",
|
||||
"Delete group…": "Excluir grupo…",
|
||||
"Delete group": "Excluir grupo",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "Os e-mails do grupo são removidos em segundo plano, e isso não pode ser desfeito.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Endereços e caixas de correio compartilhados, e as pessoas que os compartilham.",
|
||||
"Search groups": "Pesquisar grupos",
|
||||
"No groups match": "Nenhum grupo corresponde",
|
||||
"No groups yet": "Nenhum grupo ainda",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Sua organização atingiu o número de grupos permitido.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Este grupo não existe mais. Alguém pode tê-lo excluído.",
|
||||
"The server did not say whether the group was created.": "O servidor não informou se o grupo foi criado.",
|
||||
"User": "Usuário",
|
||||
"Administrator": "Administrador",
|
||||
"Custom role": "Função personalizada",
|
||||
@@ -1536,6 +1569,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} conta usa este domínio. Mova-a ou exclua-a primeiro.", other: "{n} contas usam este domínio. Mova-as ou exclua-as primeiro." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "O servidor deixa de aceitar e-mails para este domínio, e a {n} chave DKIM dele é excluída. Não é possível desfazer.", other: "O servidor deixa de aceitar e-mails para este domínio, e as {n} chaves DKIM dele são excluídas. Não é possível desfazer." },
|
||||
"{n} domains": { one: "{n} domínio", other: "{n} domínios" },
|
||||
"{n} groups": { one: "{n} grupo", other: "{n} grupos" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "O {n} membro é retirado do grupo primeiro e perde o que era compartilhado com ele. Os e-mails do grupo são removidos em segundo plano, e isso não pode ser desfeito.", other: "Os {n} membros são retirados do grupo primeiro e perdem o que era compartilhado com ele. Os e-mails do grupo são removidos em segundo plano, e isso não pode ser desfeito." },
|
||||
"{n} mailing lists": { one: "{n} lista de e-mails", other: "{n} listas de e-mails" },
|
||||
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
|
||||
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
|
||||
|
||||
@@ -143,6 +143,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Не удалось загрузить",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в собственной панели администрирования Stalwart.",
|
||||
"Open Stalwart admin": "Открыть администрирование Stalwart",
|
||||
"Default group role": "Роль группы по умолчанию",
|
||||
"A group needs an address.": "Группе нужен адрес.",
|
||||
"New group": "Новая группа",
|
||||
"Your role lets you view groups but not change them.": "Ваша роль позволяет просматривать группы, но не изменять их.",
|
||||
"No domains are available to create a group on.": "Нет доменов, на которых можно создать группу.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "Почта на эти адреса доставляется в эту группу. Изменения применяются при сохранении.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Что может делать сама группа. Участники сохраняют свои роли: группа даёт им то, чем с ней поделились, а не свои разрешения. Предлагаются только роли, разрешения которых есть у вас самих.",
|
||||
"Loading the group's members…": "Загрузка участников группы…",
|
||||
"This group has more members than can be taken out at once.": "В этой группе больше участников, чем можно убрать за один раз.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "При удалении группы сначала из неё убираются участники, а ваша роль не позволяет изменять их учётные записи.",
|
||||
"Create group": "Создать группу",
|
||||
"Added {address} to the group": "{address} добавлен в группу",
|
||||
"Removed {address} from the group": "{address} убран из группы",
|
||||
"Remove {address} from the group": "Убрать {address} из группы",
|
||||
"You can't change your own group memberships.": "Нельзя менять собственное участие в группах.",
|
||||
"Remove from group": "Убрать из группы",
|
||||
"No members yet": "Участников пока нет",
|
||||
"Showing {shown} of {total} members.": "Показано участников: {shown} из {total}.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Участники получают то, чем поделились с группой, например её почтовый ящик. Изменения применяются сразу.",
|
||||
"Add a member by name or address": "Добавить участника по имени или адресу",
|
||||
"Add a member": "Добавить участника",
|
||||
"No one else matches": "Больше никто не подходит",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Удаляет группу и её почтовый ящик. Собственные учётные записи участников остаются.",
|
||||
"Delete group…": "Удалить группу…",
|
||||
"Delete group": "Удалить группу",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "Почта группы удаляется в фоновом режиме, и это нельзя отменить.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Общие адреса и почтовые ящики, и люди, которые ими пользуются.",
|
||||
"Search groups": "Поиск групп",
|
||||
"No groups match": "Нет подходящих групп",
|
||||
"No groups yet": "Групп пока нет",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Ваша организация достигла допустимого числа групп.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Этой группы больше нет. Возможно, её кто-то удалил.",
|
||||
"The server did not say whether the group was created.": "Сервер не сообщил, создана ли группа.",
|
||||
"User": "Пользователь",
|
||||
"Administrator": "Администратор",
|
||||
"Custom role": "Особая роль",
|
||||
@@ -1535,6 +1568,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "Этот домен использует {n} учётная запись. Сначала перенесите или удалите её.", few: "Этот домен используют {n} учётные записи. Сначала перенесите или удалите их.", many: "Этот домен используют {n} учётных записей. Сначала перенесите или удалите их.", other: "Этот домен используют {n} учётной записи. Сначала перенесите или удалите их." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестанет принимать почту для этого домена, и его {n} ключ DKIM будет удалён. Отменить это нельзя.", few: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя.", many: "Сервер перестанет принимать почту для этого домена, и его {n} ключей DKIM будут удалены. Отменить это нельзя.", other: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя." },
|
||||
"{n} domains": { one: "{n} домен", few: "{n} домена", many: "{n} доменов", other: "{n} домена" },
|
||||
"{n} groups": { one: "{n} группа", few: "{n} группы", many: "{n} групп", other: "{n} группы" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Сначала {n} участник убирается из группы и теряет то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", few: "Сначала {n} участника убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", many: "Сначала {n} участников убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить.", other: "Сначала {n} участника убираются из группы и теряют то, чем с ней поделились. Почта группы удаляется в фоновом режиме, и это нельзя отменить." },
|
||||
"{n} mailing lists": { 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} другого объекта" },
|
||||
|
||||
@@ -137,6 +137,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "Не вдалося завантажити",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Докладні метрики, черга доставки, журнали та налаштування сервера є у власній панелі адміністрування Stalwart.",
|
||||
"Open Stalwart admin": "Відкрити адміністрування Stalwart",
|
||||
"Default group role": "Роль групи за замовчуванням",
|
||||
"A group needs an address.": "Групі потрібна адреса.",
|
||||
"New group": "Нова група",
|
||||
"Your role lets you view groups but not change them.": "Ваша роль дозволяє переглядати групи, але не змінювати їх.",
|
||||
"No domains are available to create a group on.": "Немає доменів, на яких можна створити групу.",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "Пошта на ці адреси доставляється в цю групу. Зміни застосовуються після збереження.",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "Що може робити сама група. Учасники зберігають свої ролі: група дає їм те, чим із нею поділилися, а не свої дозволи. Пропонуються лише ролі, дозволи яких маєте ви самі.",
|
||||
"Loading the group's members…": "Завантаження учасників групи…",
|
||||
"This group has more members than can be taken out at once.": "У цій групі більше учасників, ніж можна прибрати за один раз.",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "Під час видалення групи спочатку з неї прибираються учасники, а ваша роль не дозволяє змінювати їхні облікові записи.",
|
||||
"Create group": "Створити групу",
|
||||
"Added {address} to the group": "{address} додано до групи",
|
||||
"Removed {address} from the group": "{address} прибрано з групи",
|
||||
"Remove {address} from the group": "Прибрати {address} з групи",
|
||||
"You can't change your own group memberships.": "Не можна змінювати власну участь у групах.",
|
||||
"Remove from group": "Прибрати з групи",
|
||||
"No members yet": "Учасників поки немає",
|
||||
"Showing {shown} of {total} members.": "Показано учасників: {shown} з {total}.",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "Учасники отримують те, чим поділилися з групою, наприклад її поштову скриньку. Зміни застосовуються одразу.",
|
||||
"Add a member by name or address": "Додати учасника за іменем або адресою",
|
||||
"Add a member": "Додати учасника",
|
||||
"No one else matches": "Більше ніхто не підходить",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "Видаляє групу та її поштову скриньку. Власні облікові записи учасників залишаються.",
|
||||
"Delete group…": "Видалити групу…",
|
||||
"Delete group": "Видалити групу",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "Пошта групи видаляється у фоновому режимі, і це не можна скасувати.",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "Спільні адреси й поштові скриньки та люди, які ними користуються.",
|
||||
"Search groups": "Пошук груп",
|
||||
"No groups match": "Немає відповідних груп",
|
||||
"No groups yet": "Груп поки немає",
|
||||
"Your organisation has reached the number of groups it is allowed.": "Ваша організація досягла дозволеної кількості груп.",
|
||||
"This group no longer exists. Someone may have deleted it.": "Цієї групи більше немає. Можливо, її хтось видалив.",
|
||||
"The server did not say whether the group was created.": "Сервер не повідомив, чи створено групу.",
|
||||
"User": "Користувач",
|
||||
"Administrator": "Адміністратор",
|
||||
"Custom role": "Власна роль",
|
||||
@@ -1529,6 +1562,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { one: "Цей домен використовує {n} обліковий запис. Спершу перенесіть або видаліть його.", few: "Цей домен використовують {n} облікові записи. Спершу перенесіть або видаліть їх.", many: "Цей домен використовують {n} облікових записів. Спершу перенесіть або видаліть їх.", other: "Цей домен використовують {n} облікового запису. Спершу перенесіть або видаліть їх." },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестане приймати пошту для цього домену, і його {n} ключ DKIM буде видалено. Скасувати це неможливо.", few: "Сервер перестане приймати пошту для цього домену, і його {n} ключі DKIM буде видалено. Скасувати це неможливо.", many: "Сервер перестане приймати пошту для цього домену, і його {n} ключів DKIM буде видалено. Скасувати це неможливо.", other: "Сервер перестане приймати пошту для цього домену, і його {n} ключа DKIM буде видалено. Скасувати це неможливо." },
|
||||
"{n} domains": { one: "{n} домен", few: "{n} домени", many: "{n} доменів", other: "{n} домену" },
|
||||
"{n} groups": { one: "{n} група", few: "{n} групи", many: "{n} груп", other: "{n} групи" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Спочатку {n} учасник прибирається з групи й утрачає те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", few: "Спочатку {n} учасники прибираються з групи й утрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", many: "Спочатку {n} учасників прибирають із групи, і вони втрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати.", other: "Спочатку {n} учасника прибирають із групи, і вони втрачають те, чим із нею поділилися. Пошта групи видаляється у фоновому режимі, і це не можна скасувати." },
|
||||
"{n} mailing lists": { 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} іншого об'єкта" },
|
||||
|
||||
@@ -139,6 +139,39 @@ export const catalog: Catalog = {
|
||||
"Could not be loaded": "无法加载",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "详细指标、投递队列、日志和服务器设置位于 Stalwart 自身的管理界面中。",
|
||||
"Open Stalwart admin": "打开 Stalwart 管理界面",
|
||||
"Default group role": "默认群组角色",
|
||||
"A group needs an address.": "群组需要一个地址。",
|
||||
"New group": "新建群组",
|
||||
"Your role lets you view groups but not change them.": "您的角色可以查看群组,但不能更改。",
|
||||
"No domains are available to create a group on.": "没有可用于创建群组的域名。",
|
||||
"Mail to these addresses is delivered to this group. Changes apply when you save.": "发往这些地址的邮件会投递到此群组。更改在保存后生效。",
|
||||
"What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.": "群组本身可以做的事。成员保留各自的角色:群组给成员的是与群组共享的内容,而不是群组的权限。只提供您自己拥有其权限的角色。",
|
||||
"Loading the group's members…": "正在加载群组成员…",
|
||||
"This group has more members than can be taken out at once.": "此群组的成员多于一次可以移除的数量。",
|
||||
"Deleting a group takes its members out of it first, and your role can't change their accounts.": "删除群组会先将其成员移出,而您的角色无法更改他们的账户。",
|
||||
"Create group": "创建群组",
|
||||
"Added {address} to the group": "已将 {address} 添加到群组",
|
||||
"Removed {address} from the group": "已将 {address} 移出群组",
|
||||
"Remove {address} from the group": "将 {address} 移出群组",
|
||||
"You can't change your own group memberships.": "您不能更改自己的群组成员身份。",
|
||||
"Remove from group": "移出群组",
|
||||
"No members yet": "还没有成员",
|
||||
"Showing {shown} of {total} members.": "显示 {total} 位成员中的 {shown} 位。",
|
||||
"Members get what is shared with the group, such as its mailbox. Changes apply straight away.": "成员可以使用与群组共享的内容,例如群组的邮箱。更改会立即生效。",
|
||||
"Add a member by name or address": "按姓名或地址添加成员",
|
||||
"Add a member": "添加成员",
|
||||
"No one else matches": "没有其他匹配的人",
|
||||
"Deletes the group and its mailbox. Its members' own accounts stay.": "删除群组及其邮箱。成员自己的账户会保留。",
|
||||
"Delete group…": "删除群组…",
|
||||
"Delete group": "删除群组",
|
||||
"The group's own mail is removed in the background, and it can't be undone.": "群组的邮件会在后台删除,且无法撤销。",
|
||||
"Shared addresses and mailboxes, and the people who share them.": "共享的地址和邮箱,以及共享它们的人。",
|
||||
"Search groups": "搜索群组",
|
||||
"No groups match": "没有匹配的群组",
|
||||
"No groups yet": "还没有群组",
|
||||
"Your organisation has reached the number of groups it is allowed.": "您的组织已达到允许的群组数量。",
|
||||
"This group no longer exists. Someone may have deleted it.": "此群组已不存在。可能已被他人删除。",
|
||||
"The server did not say whether the group was created.": "服务器未说明群组是否已创建。",
|
||||
"User": "用户",
|
||||
"Administrator": "管理员",
|
||||
"Custom role": "自定义角色",
|
||||
@@ -1540,6 +1573,8 @@ export const catalog: Catalog = {
|
||||
"{n} accounts use this domain. Move or delete them first.": { other: "有 {n} 个账户正在使用此域名。请先移动或删除它们。" },
|
||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "服务器将不再接收此域名的邮件,其 {n} 个 DKIM 密钥也会被删除。此操作无法撤销。" },
|
||||
"{n} domains": { other: "{n} 个域名" },
|
||||
"{n} groups": { other: "{n} 个群组" },
|
||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { other: "会先将 {n} 位成员移出群组,他们将失去与群组共享的内容。群组的邮件会在后台删除,且无法撤销。" },
|
||||
"{n} mailing lists": { other: "{n} 个邮件列表" },
|
||||
"{n} DKIM keys": { other: "{n} 个 DKIM 密钥" },
|
||||
"{n} other items": { other: "其他 {n} 项" },
|
||||
|
||||
@@ -1764,6 +1764,13 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.admin-card-label { font-size: .85em; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.admin-card-value { font-size: 1.9em; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; min-height: 1.15em; }
|
||||
.admin-card-placeholder { display: inline-block; width: 3.5ch; height: .9em; border-radius: var(--radius-sm); background: var(--bg-sunken); vertical-align: middle; }
|
||||
.admin-members { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.admin-members li { display: flex; align-items: center; gap: 10px; padding: 7px 8px 7px 10px; border-bottom: 1px solid var(--border); }
|
||||
.admin-members li:last-child { border-bottom: 0; }
|
||||
.admin-members li.hint { display: block; }
|
||||
.admin-add-member { margin-top: 10px; }
|
||||
.admin-add-member .admin-search { max-width: none; display: block; }
|
||||
.admin-suggestions { margin-top: 6px; }
|
||||
.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; }
|
||||
|
||||
@@ -348,13 +348,15 @@ function PasswordReset({ account, disabled, onDone }: { account: DirectoryAccoun
|
||||
);
|
||||
}
|
||||
|
||||
function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName }: {
|
||||
export function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domainName, hint }: {
|
||||
aliases: EmailAlias[];
|
||||
setAliases: (a: EmailAlias[]) => void;
|
||||
editable: boolean;
|
||||
domains: { id: string; name: string }[];
|
||||
defaultDomain: string;
|
||||
domainName: (id: string) => string;
|
||||
/** What mail to these addresses does, when it is not reaching this account. */
|
||||
hint?: string;
|
||||
}) {
|
||||
const [local, setLocal] = useState("");
|
||||
const [domain, setDomain] = useState(defaultDomain);
|
||||
@@ -396,7 +398,7 @@ function Aliases({ aliases, setAliases, editable, domains, defaultDomain, domain
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editable && <p className="hint">{t("Mail to these addresses is delivered to this account. Changes apply when you save.")}</p>}
|
||||
{editable && <p className="hint">{hint ?? t("Mail to these addresses is delivered to this account. Changes apply when you save.")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Globe, LayoutDashboard, User } from "lucide-react";
|
||||
import { Globe, LayoutDashboard, User, UsersRound } from "lucide-react";
|
||||
import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
@@ -8,6 +8,7 @@ import { usePermissions } from "./usePermissions";
|
||||
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
|
||||
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
|
||||
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
|
||||
groups: { group: "Directory", label: "Groups", icon: <UsersRound size={20} /> },
|
||||
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import { adminSections, type AdminSection } from "@/lib/adminAccess";
|
||||
import { AccountsAdmin } from "./AccountsAdmin";
|
||||
import { AdminDashboard } from "./AdminDashboard";
|
||||
import { DomainsAdmin } from "./DomainsAdmin";
|
||||
import { GroupsAdmin } from "./GroupsAdmin";
|
||||
import { currentAdminSection } from "./AdminNav";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
|
||||
dashboard: () => <AdminDashboard />,
|
||||
accounts: (id) => <AccountsAdmin selectedId={id} />,
|
||||
groups: (id) => <GroupsAdmin selectedId={id} />,
|
||||
domains: (id) => <DomainsAdmin selectedId={id} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search, Trash2, UserMinus, UserPlus, X } from "lucide-react";
|
||||
import { can, canGrantRole } from "@/lib/adminAccess";
|
||||
import { aliasList, describeDirectoryError, quotasWithDisk, updateAccount, DISK_QUOTA, type EmailAlias } from "@/lib/adminDirectory";
|
||||
import {
|
||||
createGroup,
|
||||
destroyGroup,
|
||||
groupRoleKey,
|
||||
groupRolesFromKey,
|
||||
listMembers,
|
||||
searchUsers,
|
||||
setMembership,
|
||||
type DirectoryGroup,
|
||||
type GroupMember,
|
||||
} from "@/lib/adminGroups";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Avatar, Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Aliases } from "./AccountSheet";
|
||||
import { isSelf, type DirectoryContext } from "./directoryContext";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
const gibOf = (bytes: number | undefined) => (bytes ? String(Math.round((bytes / GIB) * 10) / 10) : "");
|
||||
const bytesOf = (gib: string) => {
|
||||
const n = Number(gib.replace(",", "."));
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n * GIB) : null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Null to create one. */
|
||||
group: DirectoryGroup | null;
|
||||
ctx: DirectoryContext;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One group, opened beside the list.
|
||||
*
|
||||
* Its own fields save together, as an account's do. Members are not part of
|
||||
* that save: each is a change to the *member's* account, made when it is
|
||||
* asked for, because that is where Stalwart keeps it and a half-saved list of
|
||||
* people is worse than a list that is always what the server has.
|
||||
*/
|
||||
export function GroupSheet({ group, ctx, onClose, onChanged, onCreated, onDeleted }: Props) {
|
||||
const perms = usePermissions();
|
||||
const creating = group === null;
|
||||
const editable = creating ? can(perms, "Account", "Create") : can(perms, "Account", "Update");
|
||||
|
||||
const [description, setDescription] = useState(group?.description ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [domainId, setDomainId] = useState(ctx.domains[0]?.id ?? "");
|
||||
const [role, setRole] = useState(groupRoleKey(group?.roles));
|
||||
const [quota, setQuota] = useState(gibOf(group?.quotas?.[DISK_QUOTA]));
|
||||
const [aliases, setAliases] = useState<EmailAlias[]>(() => Object.values(group?.aliases ?? {}));
|
||||
const [members, setMembers] = useState<{ members: GroupMember[]; total: number } | null>(null);
|
||||
const [membersError, setMembersError] = useState<string | null>(null);
|
||||
const [membersRevision, setMembersRevision] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!domainId && ctx.domains[0]) setDomainId(ctx.domains[0].id);
|
||||
}, [ctx.domains, domainId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return;
|
||||
let cancelled = false;
|
||||
setMembersError(null);
|
||||
listMembers(group.id).then(
|
||||
(m) => !cancelled && setMembers(m),
|
||||
(err) => {
|
||||
if (cancelled) return;
|
||||
setMembers({ members: [], total: 0 });
|
||||
setMembersError(describeDirectoryError(err, "group"));
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [group, membersRevision]);
|
||||
|
||||
const domainName = (id: string) => ctx.domains.find((d) => d.id === id)?.name ?? "";
|
||||
const address = group?.emailAddress ?? `${name}@${domainName(domainId)}`;
|
||||
|
||||
const roleOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [{ value: "Default", label: t("Default group role") }];
|
||||
for (const r of ctx.roles?.values() ?? []) {
|
||||
if (canGrantRole(perms, r.id, ctx.roles)) options.push({ value: `custom:${r.id}`, label: r.description || r.id });
|
||||
}
|
||||
if (!options.some((o) => o.value === role)) {
|
||||
const ids = role.startsWith("custom:") ? role.slice(7).split(",") : [];
|
||||
const label = ids.map((id) => ctx.roles?.get(id)?.description).filter(Boolean).join(", ") || t("Custom role");
|
||||
options.push({ value: role, label });
|
||||
}
|
||||
return options;
|
||||
}, [perms, ctx.roles, role]);
|
||||
|
||||
const run = async (work: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "group"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
run(async () => {
|
||||
if (!group) {
|
||||
if (!name.trim() || !domainId) {
|
||||
setError(t("A group needs an address."));
|
||||
return;
|
||||
}
|
||||
const id = await createGroup({ name, domainId, description, roles: groupRolesFromKey(role), diskQuotaBytes: bytesOf(quota) });
|
||||
toast.success(t("Created {address}", { address }));
|
||||
onCreated(id);
|
||||
return;
|
||||
}
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ((group.description ?? "") !== description) patch.description = description.trim() || null;
|
||||
if (groupRoleKey(group.roles) !== role) patch.roles = groupRolesFromKey(role);
|
||||
if ((group.quotas?.[DISK_QUOTA] ?? null) !== bytesOf(quota)) patch.quotas = quotasWithDisk(group.quotas, bytesOf(quota));
|
||||
if (JSON.stringify(aliasList(Object.values(group.aliases ?? {}))) !== JSON.stringify(aliasList(aliases))) patch.aliases = aliasList(aliases);
|
||||
if (!Object.keys(patch).length) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await updateAccount(group.id, patch);
|
||||
toast.success(t("Saved {address}", { address }));
|
||||
onChanged();
|
||||
});
|
||||
|
||||
const membersChanged = () => {
|
||||
setMembersRevision((n) => n + 1);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const used = group?.usedDiskQuota ?? 0;
|
||||
const limit = group?.quotas?.[DISK_QUOTA];
|
||||
|
||||
return (
|
||||
<aside className="admin-sheet" aria-label={creating ? t("New group") : address}>
|
||||
<div className="admin-sheet-head">
|
||||
{group && <Avatar who={{ name: group.description || group.name, email: group.emailAddress }} />}
|
||||
<div className="grow">
|
||||
<h2 className="truncate">{creating ? t("New group") : group.description || group.name}</h2>
|
||||
{group && <div className="hint truncate notranslate" translate="no">{group.emailAddress}</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 groups but not change them.")}</p>}
|
||||
|
||||
<h3>{t("Profile")}</h3>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-group-description">{t("Display name")}</label>
|
||||
<input id="admin-group-description" className="input" value={description} disabled={!editable} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="field">
|
||||
<label htmlFor="admin-group-name">{t("Address")}</label>
|
||||
<div className="row admin-address">
|
||||
<input id="admin-group-name" className="input" value={name} autoComplete="off" spellCheck={false} onChange={(e) => setName(e.target.value.trim().toLowerCase())} />
|
||||
<span className="muted">@</span>
|
||||
<select className="input" aria-label={t("Domain")} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
|
||||
{ctx.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!ctx.domains.length && <span className="hint">{t("No domains are available to create a group on.")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!creating && (
|
||||
<>
|
||||
<h3>{t("Members")}</h3>
|
||||
<Members
|
||||
group={group}
|
||||
ctx={ctx}
|
||||
members={members}
|
||||
error={membersError}
|
||||
editable={editable}
|
||||
onChanged={membersChanged}
|
||||
/>
|
||||
|
||||
<h3>{t("Other addresses")}</h3>
|
||||
<Aliases
|
||||
aliases={aliases}
|
||||
setAliases={setAliases}
|
||||
editable={editable}
|
||||
domains={ctx.domains}
|
||||
defaultDomain={group.domainId}
|
||||
domainName={domainName}
|
||||
hint={t("Mail to these addresses is delivered to this group. Changes apply when you save.")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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("What the group itself may do. Members keep their own roles: a group gives them what is shared with it, not its permissions. Only roles whose permissions you hold yourself are offered.")}</p>
|
||||
|
||||
<h3>{t("Storage")}</h3>
|
||||
{!creating && (
|
||||
<p className="hint" style={{ marginTop: 0 }}>
|
||||
{limit ? t("{used} of {total}", { used: formatSize(used), total: formatSize(limit) }) : t("{used} · no limit", { used: formatSize(used) })}
|
||||
</p>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="admin-group-quota">{t("Limit in GB")}</label>
|
||||
<input id="admin-group-quota" className="input admin-narrow" inputMode="decimal" value={quota} disabled={!editable} placeholder={t("No limit")} onChange={(e) => setQuota(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{!creating && can(perms, "Account", "Destroy") && (
|
||||
<DeleteGroup
|
||||
group={group}
|
||||
memberIds={members?.members.map((m) => m.id) ?? null}
|
||||
total={members?.total ?? 0}
|
||||
blocked={
|
||||
members === null
|
||||
? t("Loading the group's members…")
|
||||
: members.total > members.members.length
|
||||
? t("This group has more members than can be taken out at once.")
|
||||
: members.total > 0 && !can(perms, "Account", "Update")
|
||||
? t("Deleting a group takes its members out of it first, and your role can't change their accounts.")
|
||||
: null
|
||||
}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="admin-sheet-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t("Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={busy || (creating && (!name || !domainId))} onClick={() => void save()}>
|
||||
{creating ? t("Create group") : t("Save changes")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Members({ group, ctx, members, error, editable, onChanged }: {
|
||||
group: DirectoryGroup;
|
||||
ctx: DirectoryContext;
|
||||
members: { members: GroupMember[]; total: number } | null;
|
||||
error: string | null;
|
||||
editable: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
|
||||
const change = async (member: GroupMember, join: boolean) => {
|
||||
setBusyId(member.id);
|
||||
setFailure(null);
|
||||
try {
|
||||
await setMembership([member.id], group.id, join);
|
||||
const who = member.emailAddress ?? member.name;
|
||||
toast.success(join ? t("Added {address} to the group", { address: who }) : t("Removed {address} from the group", { address: who }));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setFailure(describeDirectoryError(err, "group"));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (members === null) return <Spinner />;
|
||||
const present = new Set(members.members.map((m) => m.id));
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
{members.members.length ? (
|
||||
<ul className="admin-members">
|
||||
{members.members.map((m) => {
|
||||
const self = isSelf(m, ctx);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Avatar who={{ name: m.description || m.name, email: m.emailAddress }} size="sm" />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="truncate">
|
||||
{m.description || m.name}
|
||||
{self && <span className="badge muted">{t("You")}</span>}
|
||||
</div>
|
||||
<div className="hint truncate notranslate" translate="no">{m.emailAddress}</div>
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
aria-label={t("Remove {address} from the group", { address: m.emailAddress ?? m.name })}
|
||||
title={self ? t("You can't change your own group memberships.") : t("Remove from group")}
|
||||
disabled={self || busyId !== null}
|
||||
onClick={() => void change(m, false)}
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
!error && <p className="hint" style={{ marginTop: 0 }}>{t("No members yet")}</p>
|
||||
)}
|
||||
{members.total > members.members.length && (
|
||||
<p className="hint">{t("Showing {shown} of {total} members.", { shown: members.members.length, total: members.total })}</p>
|
||||
)}
|
||||
{failure && <p className="admin-notice error" role="alert">{failure}</p>}
|
||||
{editable && <AddMember ctx={ctx} exclude={present} busy={busyId !== null} onAdd={(m) => void change(m, true)} />}
|
||||
<p className="hint">{t("Members get what is shared with the group, such as its mailbox. Changes apply straight away.")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMember({ ctx, exclude, busy, onAdd }: { ctx: DirectoryContext; exclude: Set<string>; busy: boolean; onAdd: (m: GroupMember) => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [found, setFound] = useState<GroupMember[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const needle = text.trim();
|
||||
if (!needle) {
|
||||
setFound(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const id = window.setTimeout(() => {
|
||||
searchUsers(needle).then(
|
||||
(list) => !cancelled && setFound(list),
|
||||
() => !cancelled && setFound([]),
|
||||
);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(id);
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const offered = (found ?? []).filter((m) => !exclude.has(m.id));
|
||||
return (
|
||||
<div className="admin-add-member">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={text} placeholder={t("Add a member by name or address")} aria-label={t("Add a member")} onChange={(e) => setText(e.target.value)} />
|
||||
</label>
|
||||
{found !== null && (
|
||||
<ul className="admin-members admin-suggestions">
|
||||
{offered.length ? (
|
||||
offered.map((m) => {
|
||||
const self = isSelf(m, ctx);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Avatar who={{ name: m.description || m.name, email: m.emailAddress }} size="sm" />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="truncate">{m.description || m.name}</div>
|
||||
<div className="hint truncate notranslate" translate="no">{m.emailAddress}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={busy || self}
|
||||
title={self ? t("You can't change your own group memberships.") : undefined}
|
||||
onClick={() => {
|
||||
onAdd(m);
|
||||
setText("");
|
||||
}}
|
||||
>
|
||||
<UserPlus size={14} /> {t("Add")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li className="hint">{t("No one else matches")}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteGroup({ group, memberIds, total, blocked, onDeleted }: { group: DirectoryGroup; memberIds: string[] | null; total: number; blocked: string | null; onDeleted: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const address = group.emailAddress ?? group.name;
|
||||
return (
|
||||
<>
|
||||
<h3>{t("Delete")}</h3>
|
||||
<div className="admin-danger">
|
||||
<p>{blocked ?? t("Deletes the group and its mailbox. Its members' own accounts stay.")}</p>
|
||||
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
|
||||
<Trash2 size={14} /> {t("Delete group…")}
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("Delete {address}?", { address })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await destroyGroup(group.id, memberIds ?? []);
|
||||
toast.success(t("Deleted {address}", { address }));
|
||||
setOpen(false);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(describeDirectoryError(err, "group"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Delete group")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{total > 0
|
||||
? plural(total, {
|
||||
one: "Its {n} member is taken out of the group first, and loses what was shared with it. The group's own mail is removed in the background, and it can't be undone.",
|
||||
other: "Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.",
|
||||
})
|
||||
: t("The group's own mail is removed in the background, and it can't be undone.")}
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-group-delete-confirm">{t("Type {address} to confirm", { address })}</label>
|
||||
<input id="admin-group-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Plus, Search, UsersRound } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { STALWART_CAP } from "@/jmap/client";
|
||||
import { can, type RoleDef } from "@/lib/adminAccess";
|
||||
import { describeDirectoryError, listDomains, listRoles, type DirectoryDomain } from "@/lib/adminDirectory";
|
||||
import { countMembers, getGroups, queryGroups, type DirectoryGroup } from "@/lib/adminGroups";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Avatar, Empty, Spinner } from "@/ui/misc";
|
||||
import { usePermissions } from "./usePermissions";
|
||||
import type { DirectoryContext } from "./directoryContext";
|
||||
import { GroupSheet } from "./GroupSheet";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Groups: accounts that hold shared mail and the people who share it.
|
||||
*
|
||||
* Laid out as Accounts is -- search, a page of fifty, a panel beside the list
|
||||
* -- because a group is an account to the server, with a member count where a
|
||||
* person has a role.
|
||||
*/
|
||||
export function GroupsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const perms = usePermissions();
|
||||
const session = useSession((s) => s.session);
|
||||
const [text, setText] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [position, setPosition] = useState(0);
|
||||
const [page, setPage] = useState<{ groups: DirectoryGroup[]; total: number } | null>(null);
|
||||
const [counts, setCounts] = useState<Map<string, number>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [serverDomains, setServerDomains] = useState<DirectoryDomain[] | null>(null);
|
||||
const [roles, setRoles] = useState<Map<string, RoleDef> | null>(null);
|
||||
const [loose, setLoose] = useState<DirectoryGroup | 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 queryGroups({ text: query, position, limit: PAGE_SIZE });
|
||||
const groups = await getGroups(q.ids);
|
||||
if (cancelled) return;
|
||||
setPage({ groups, total: q.total });
|
||||
void countMembers(groups.map((g) => g.id)).then((c) => !cancelled && setCounts(c));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPage({ groups: [], total: 0 });
|
||||
setError(describeDirectoryError(err, "group"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, position, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) void listDomains().then(setServerDomains, () => setServerDomains(null));
|
||||
if (can(perms, "Role", "Query") && can(perms, "Role", "Get")) void listRoles().then((list) => setRoles(new Map(list.map((r) => [r.id, r]))), () => setRoles(null));
|
||||
}, [perms, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedId === "new" || page?.groups.some((g) => g.id === selectedId)) {
|
||||
setLoose(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getGroups([selectedId]).then(
|
||||
([g]) => { if (!cancelled) setLoose(g ?? null); },
|
||||
() => { if (!cancelled) setLoose(null); },
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId, page]);
|
||||
|
||||
const ctx: DirectoryContext = useMemo(() => {
|
||||
const seen = new Map<string, DirectoryDomain>();
|
||||
for (const g of page?.groups ?? []) {
|
||||
const domain = g.emailAddress?.split("@")[1];
|
||||
if (domain && !seen.has(g.domainId)) seen.set(g.domainId, { id: g.domainId, name: domain });
|
||||
}
|
||||
const ownId = session?.primaryAccounts?.[STALWART_CAP];
|
||||
return {
|
||||
domains: (serverDomains ?? [...seen.values()]).slice().sort((x, y) => x.name.localeCompare(y.name)),
|
||||
roles,
|
||||
groups: new Map(),
|
||||
self: { ids: new Set(ownId ? [ownId] : []), address: (session?.username ?? "").toLowerCase() },
|
||||
};
|
||||
}, [page, serverDomains, roles, session]);
|
||||
|
||||
const selected = selectedId && selectedId !== "new" ? (page?.groups.find((g) => g.id === selectedId) ?? loose) : null;
|
||||
const close = () => navigate("/admin/groups");
|
||||
const changed = () => setReload((n) => n + 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Groups")}</h1>
|
||||
<p className="lead">{t("Shared addresses and mailboxes, and the people who share them.")}</p>
|
||||
</div>
|
||||
{can(perms, "Account", "Create") && (
|
||||
<button className="btn btn-primary" onClick={() => navigate("/admin/groups/new")}>
|
||||
<Plus size={16} /> {t("New group")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input className="input" type="search" value={text} onChange={(e) => setText(e.target.value)} placeholder={t("Search by name or address")} aria-label={t("Search groups")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-notice error" role="alert">{error}</p>}
|
||||
|
||||
{page === null ? (
|
||||
<Spinner />
|
||||
) : page.groups.length === 0 ? (
|
||||
!error && (
|
||||
<Empty icon={<UsersRound size={32} />} title={query ? t("No groups match") : t("No groups yet")}>
|
||||
{query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined}
|
||||
</Empty>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("Group")}</th>
|
||||
<th>{t("Members")}</th>
|
||||
<th className="hide-mobile">{t("Other addresses")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{page.groups.map((g) => (
|
||||
<tr
|
||||
key={g.id}
|
||||
className={g.id === selectedId ? "selected" : ""}
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/admin/groups/${g.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/admin/groups/${g.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={t("Open {address}", { address: g.emailAddress ?? g.name })}
|
||||
>
|
||||
<td>
|
||||
<div className="admin-who">
|
||||
<Avatar who={{ name: g.description || g.name, email: g.emailAddress }} size="sm" />
|
||||
<div className="grow">
|
||||
<div className="admin-who-name truncate">{g.description || g.name}</div>
|
||||
<div className="hint truncate notranslate" translate="no">{g.emailAddress}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="muted" style={{ fontVariantNumeric: "tabular-nums" }}>{counts.has(g.id) ? counts.get(g.id) : "—"}</td>
|
||||
<td className="hide-mobile muted">
|
||||
<span className="truncate admin-groups notranslate" translate="no">{Object.values(g.aliases ?? {}).map((a) => a.name).join(", ") || "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{page.total <= PAGE_SIZE && position === 0 ? (
|
||||
<p className="hint admin-count">{plural(page.total, { one: "{n} group", other: "{n} groups" })}</p>
|
||||
) : (
|
||||
<div className="admin-pager">
|
||||
<span className="hint">{t("{from}–{to} of {total}", { from: position + 1, to: position + page.groups.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.groups.length >= page.total} onClick={() => setPosition(position + PAGE_SIZE)}><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedId === "new" || selected) && (
|
||||
<GroupSheet
|
||||
key={selectedId}
|
||||
group={selectedId === "new" ? null : selected}
|
||||
ctx={ctx}
|
||||
onClose={close}
|
||||
onChanged={changed}
|
||||
onCreated={(id) => {
|
||||
changed();
|
||||
navigate(`/admin/groups/${id}`);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
changed();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 { DirectoryGroup } from "@/lib/adminGroups";
|
||||
import type { DirectoryContext } from "../directoryContext";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
members: [
|
||||
{ id: "me", name: "demo", emailAddress: "[email protected]", description: "Demo User" },
|
||||
{ id: "u2", name: "ada", emailAddress: "[email protected]", description: "Ada Lovelace" },
|
||||
],
|
||||
setMembership: vi.fn(async () => {}),
|
||||
destroyGroup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/adminGroups", async (original) => ({
|
||||
...(await original<typeof import("@/lib/adminGroups")>()),
|
||||
listMembers: vi.fn(async () => ({ members: api.members, total: api.members.length })),
|
||||
searchUsers: vi.fn(async () => []),
|
||||
setMembership: api.setMembership,
|
||||
destroyGroup: api.destroyGroup,
|
||||
}));
|
||||
|
||||
const { GroupSheet } = await import("../GroupSheet");
|
||||
|
||||
const group: DirectoryGroup = { id: "g1", "@type": "Group", name: "support", domainId: "d1", emailAddress: "[email protected]", description: "Support", roles: { "@type": "Default" }, aliases: {} };
|
||||
const ctx: DirectoryContext = { domains: [{ id: "d1", name: "example.com" }], roles: new Map(), groups: new Map(), self: { ids: new Set(["me"]), address: "[email protected]" } };
|
||||
|
||||
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?.includes(label));
|
||||
|
||||
/** The group panel's guards: what a role may change, and what nobody may change for themselves. */
|
||||
describe("the group sheet", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(<GroupSheet group={group} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />);
|
||||
});
|
||||
await act(async () => {});
|
||||
};
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
api.setMembership.mockClear();
|
||||
api.destroyGroup.mockClear();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("lists the members, and will not take the viewer out of a group themselves", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"]);
|
||||
await render();
|
||||
expect(host.querySelectorAll(".admin-members li")).toHaveLength(2);
|
||||
expect(button(host, "Remove [email protected] from the group")?.disabled).toBe(true);
|
||||
const ada = button(host, "Remove [email protected] from the group")!;
|
||||
expect(ada.disabled).toBe(false);
|
||||
await act(async () => ada.click());
|
||||
expect(api.setMembership).toHaveBeenCalledWith(["u2"], "g1", false);
|
||||
});
|
||||
|
||||
it("offers no changes to a role that can only read", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery"]);
|
||||
await render();
|
||||
expect(host.textContent).toContain("Your role lets you view groups but not change them.");
|
||||
expect(button(host, "Remove [email protected] from the group")).toBeUndefined();
|
||||
expect(host.querySelector(".admin-add-member")).toBeNull();
|
||||
expect(button(host, "Save changes")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("will not start a delete it could only half finish", async () => {
|
||||
// Deleting takes the members out first, which is an update to each of them.
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountDestroy"]);
|
||||
await render();
|
||||
expect(button(host, "Delete group…")?.disabled).toBe(true);
|
||||
expect(host.querySelector(".admin-danger")?.textContent).toContain("your role can't change their accounts");
|
||||
});
|
||||
|
||||
it("deletes with every member taken out, once the address is typed", async () => {
|
||||
signIn(["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysAccountDestroy"]);
|
||||
await render();
|
||||
await act(async () => button(host, "Delete group…")!.click());
|
||||
const input = document.querySelector<HTMLInputElement>("#admin-group-delete-confirm")!;
|
||||
const confirm = [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent === "Delete group")!;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
set.call(input, "[email protected]");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(confirm.disabled).toBe(false);
|
||||
await act(async () => confirm.click());
|
||||
expect(api.destroyGroup).toHaveBeenCalledWith("g1", ["me", "u2"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user