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:
2026-09-15 08:31:36 -07:00
parent 4787e8bf12
commit e2a531b615
27 changed files with 1462 additions and 18 deletions
+4 -2
View File
@@ -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>
);
}
+2 -1
View File
@@ -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} /> },
};
+2
View File
@@ -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} />,
};
+469
View File
@@ -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>
</>
);
}
+214
View File
@@ -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"]);
});
});