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(() => Object.values(group?.aliases ?? {})); const [members, setMembers] = useState<{ members: GroupMember[]; total: number } | null>(null); const [membersError, setMembersError] = useState(null); const [membersRevision, setMembersRevision] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) => { 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 = {}; 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 ( ); } 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(null); const [failure, setFailure] = useState(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 ; const present = new Set(members.members.map((m) => m.id)); return (
{error &&

{error}

} {members.members.length ? (
    {members.members.map((m) => { const self = isSelf(m, ctx); return (
  • {m.description || m.name} {self && {t("You")}}
    {m.emailAddress}
    {editable && ( )}
  • ); })}
) : ( !error &&

{t("No members yet")}

)} {members.total > members.members.length && (

{t("Showing {shown} of {total} members.", { shown: members.members.length, total: members.total })}

)} {failure &&

{failure}

} {editable && void change(m, true)} />}

{t("Members get what is shared with the group, such as its mailbox. Changes apply straight away.")}

); } function AddMember({ ctx, exclude, busy, onAdd }: { ctx: DirectoryContext; exclude: Set; busy: boolean; onAdd: (m: GroupMember) => void }) { const [text, setText] = useState(""); const [found, setFound] = useState(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 (
{found !== null && (
    {offered.length ? ( offered.map((m) => { const self = isSelf(m, ctx); return (
  • {m.description || m.name}
    {m.emailAddress}
  • ); }) ) : (
  • {t("No one else matches")}
  • )}
)}
); } 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(null); const address = group.emailAddress ?? group.name; return ( <>

{t("Delete")}

{blocked ?? t("Deletes the group and its mailbox. Its members' own accounts stay.")}

setOpen(false)} title={t("Delete {address}?", { address })} size="sm" footer={ <> } >

{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.")}

setTyped(e.target.value)} />
{error &&

{error}

}
); }