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/admin/adminAccess"; import { describeDirectoryError, listDomains, listRoles, type DirectoryDomain } from "@/lib/admin/adminDirectory"; import { countMembers, getGroups, queryGroups, type DirectoryGroup } from "@/lib/admin/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>(new Map()); const [error, setError] = useState(null); const [reload, setReload] = useState(0); const [serverDomains, setServerDomains] = useState(null); const [roles, setRoles] = useState | null>(null); const [loose, setLoose] = useState(null); useEffect(() => { const id = window.setTimeout(() => { setQuery(text); setPosition(0); }, 250); return () => window.clearTimeout(id); }, [text]); useEffect(() => { let canceled = false; setError(null); void (async () => { try { const q = await queryGroups({ text: query, position, limit: PAGE_SIZE }); const groups = await getGroups(q.ids); if (canceled) return; setPage({ groups, total: q.total }); void countMembers(groups.map((g) => g.id)).then((c) => !canceled && setCounts(c)); } catch (err) { if (!canceled) { setPage({ groups: [], total: 0 }); setError(describeDirectoryError(err, "group")); } } })(); return () => { canceled = 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 canceled = false; void getGroups([selectedId]).then( ([g]) => { if (!canceled) setLoose(g ?? null); }, () => { if (!canceled) setLoose(null); }, ); return () => { canceled = true; }; }, [selectedId, page]); const ctx: DirectoryContext = useMemo(() => { const seen = new Map(); 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 (

{t("Groups")}

{t("Shared addresses and mailboxes, and the people who share them.")}

{can(perms, "Account", "Create") && ( )}
{error &&

{error}

} {page === null ? ( ) : page.groups.length === 0 ? ( !error && ( } title={query ? t("No groups match") : t("No groups yet")}> {query ? t("Nothing on your domains matches “{query}”.", { query }) : undefined} ) ) : ( <>
{page.groups.map((g) => ( 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 })} > ))}
{t("Group")} {t("Members")} {t("Other addresses")}
{g.description || g.name}
{g.emailAddress}
{counts.has(g.id) ? counts.get(g.id) : "—"} {Object.values(g.aliases ?? {}).map((a) => a.name).join(", ") || "—"}
{page.total <= PAGE_SIZE && position === 0 ? (

{plural(page.total, { one: "{n} group", other: "{n} groups" })}

) : (
{t("{from}–{to} of {total}", { from: position + 1, to: position + page.groups.length, total: page.total })}
)} )} {(selectedId === "new" || selected) && ( { changed(); navigate(`/admin/groups/${id}`); }} onDeleted={() => { changed(); close(); }} /> )}
); }