Add Groups to Administration
A group is a shared address and mailbox and the people who share it. To Stalwart it is an x:Account of type Group, behind the same sysAccount* permissions as a person, so it sits under Directory beside Accounts: search, a page of fifty with each group's member count, and a panel to create, edit and delete one. Membership lives on the member, not the group. Members are the users whose memberGroupIds name it, and adding or removing one is a single memberGroupIds/<group> pointer on that user's account -- true or null -- which leaves their other groups alone. Changes apply straight away rather than riding on Save, so the list is always what the server has. Nobody can add or remove themselves, the same line the account panel draws at one's own role. A group's role is Default or Custom, not a person's User or Admin, and it is what the group may do: in 0.16 a user's permissions come from their own roles only, and a group gives its members what is shared with it. Only roles the viewer could grant are offered. Delete takes the members out first and then deletes the group, the order a domain's keys go before the domain, because the registry keeps anything another object names. A role that cannot change the members' accounts is not offered a delete it could only half finish. The mock's groups had a person's roles, accepted a memberGroupIds filter without applying it, and answered a linked delete with the wrong shape; all three follow the source now, and it refuses nested groups and memberships of things that are not groups. Nothing about groups has been run against a live server yet: production has none, and every operation is a write. KNOWN-ISSUES says what was read from source. Thirty-five new strings, two of them plurals, in all nine catalogues.
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user