import { useEffect, useMemo, useState } from "react"; import { useLocation } from "wouter"; import { Plus, Search, ShieldCheck } from "lucide-react"; import { can } from "@/lib/adminAccess"; import { describeDirectoryError } from "@/lib/adminDirectory"; import { effectivePermissions, listAllRoles, loadPermissionList, loadRoleDefaults, type DirectoryRole, type RoleDefaults } from "@/lib/adminRoles"; import { describePermissions, loadPermissionCatalog, type PermissionEntry } from "@/lib/permissionLabels"; import { plural, t } from "@/lib/i18n"; import { Empty, Spinner } from "@/ui/misc"; import { usePermissions } from "./usePermissions"; import { defaultKinds, RoleSheet } from "./RoleSheet"; /** * Roles: named sets of permissions that accounts are given. * * All of them at once rather than paged -- a server has a handful, not * thousands -- with the permission list loaded alongside, so a panel opens * ready to edit. */ export function RolesAdmin({ selectedId }: { selectedId?: string }) { const [, navigate] = useLocation(); const perms = usePermissions(); const [text, setText] = useState(""); const [roles, setRoles] = useState(null); const [defaults, setDefaults] = useState(null); const [entries, setEntries] = useState(null); const [permissionsError, setPermissionsError] = useState(null); const [error, setError] = useState(null); const [reload, setReload] = useState(0); useEffect(() => { let cancelled = false; setError(null); listAllRoles().then( (list) => !cancelled && setRoles(list), (err) => { if (cancelled) return; setRoles([]); setError(describeDirectoryError(err, "role")); }, ); void loadRoleDefaults().then((d) => !cancelled && setDefaults(d)); return () => { cancelled = true; }; }, [reload]); // The permission list changes only when Stalwart is upgraded; once per visit is plenty. useEffect(() => { let cancelled = false; Promise.all([loadPermissionList(), loadPermissionCatalog()]).then( ([list, catalog]) => !cancelled && setEntries(describePermissions(list, catalog, t("General"))), (err) => !cancelled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })), ); return () => { cancelled = true; }; }, []); const byId = useMemo(() => new Map((roles ?? []).map((r) => [r.id, r])), [roles]); const needle = text.trim().toLowerCase(); const shown = (roles ?? []).filter((r) => !needle || (r.description ?? r.id).toLowerCase().includes(needle)); const selected = selectedId && selectedId !== "new" ? byId.get(selectedId) : null; const close = () => navigate("/admin/roles"); const changed = () => setReload((n) => n + 1); return (

{t("Roles")}

{t("Named sets of permissions, given to accounts, groups and tenants.")}

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

{error}

} {roles === null ? ( ) : shown.length === 0 ? ( !error && } title={needle ? t("No roles match") : t("No roles yet")} /> ) : ( <>
{shown.map((r) => { const kinds = defaultKinds(r.id, defaults); return ( navigate(`/admin/roles/${r.id}`)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate(`/admin/roles/${r.id}`); } }} aria-label={t("Open {name}", { name: r.description || r.id })} > ); })}
{t("Role")} {t("Permissions")} {t("Builds on")}
{r.description || r.id}
{kinds.length > 0 &&
{t("Default for {kinds}", { kinds: kinds.join(", ") })}
}
{effectivePermissions(r, byId, r.id).size} {Object.keys(r.roleIds ?? {}).map((id) => byId.get(id)?.description || id).join(", ") || "—"}

{plural(roles.length, { one: "{n} role", other: "{n} roles" })}

)} {(selectedId === "new" || selected) && roles && ( { changed(); navigate(`/admin/roles/${id}`); }} onDeleted={() => { changed(); close(); }} /> )}
); }