import { useEffect, useMemo, useState } from "react"; import { Lock, Search, Trash2, X } from "lucide-react"; import { can, canGrantRole } from "@/lib/admin/adminAccess"; import { describeDirectoryError } from "@/lib/admin/adminDirectory"; import { describeLinked, DomainError } from "@/lib/admin/adminDomains"; import { canBuildOn, createRole, destroyRole, effectivePermissions, inherited, roleOutranks, setPatch, updateRole, type DirectoryRole, type PermissionState, type RoleDefaults, } from "@/lib/admin/adminRoles"; import type { PermissionEntry } from "@/lib/permissionLabels"; import { plural, t } from "@/lib/i18n"; import { Dialog } from "@/ui/dialog"; import { Spinner } from "@/ui/misc"; import { toast } from "@/ui/toast"; import { usePermissions } from "./usePermissions"; interface Props { /** Null to create one. */ role: DirectoryRole | null; roles: ReadonlyMap; defaults: RoleDefaults | null; /** Stalwart's permissions in the reader's language; null while loading, and on failure with `permissionsError`. */ entries: PermissionEntry[] | null; permissionsError: string | null; onClose: () => void; onChanged: () => void; onCreated: (id: string) => void; onDeleted: () => void; } /** The kinds of account a role can be the default for, in the order they are named. */ export function defaultKinds(id: string, defaults: RoleDefaults | null): string[] { if (!defaults) return []; const out: string[] = []; if (defaults.user.includes(id)) out.push(t("users")); if (defaults.group.includes(id)) out.push(t("groups")); if (defaults.tenant.includes(id)) out.push(t("tenant administrators")); if (defaults.admin.includes(id)) out.push(t("administrators")); return out; } type Show = "all" | "granted" | "set"; /** * One role, opened beside the list. * * Description, the roles it builds on and its own permissions save together. * What Save sends for the sets is one pointer per permission or role added or * taken away, so nothing it did not touch moves. */ export function RoleSheet({ role, roles, defaults, entries, permissionsError, onClose, onChanged, onCreated, onDeleted }: Props) { const perms = usePermissions(); const creating = role === null; const locked = role ? roleOutranks(perms, role, roles) : false; const editable = creating ? can(perms, "Role", "Create") : can(perms, "Role", "Update") && !locked; const kinds = role ? defaultKinds(role.id, defaults) : []; const [description, setDescription] = useState(role?.description ?? ""); const [bases, setBases] = useState(() => Object.keys(role?.roleIds ?? {})); const [enabled, setEnabled] = useState>(() => new Set(Object.keys(role?.enabledPermissions ?? {}))); const [disabled, setDisabled] = useState>(() => new Set(Object.keys(role?.disabledPermissions ?? {}))); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !document.querySelector(".dialog-backdrop")) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const base = useMemo(() => inherited(bases, roles, role?.id), [bases, roles, role]); const effective = useMemo(() => effectivePermissions({ roleIds: Object.fromEntries(bases.map((b) => [b, true])), enabledPermissions: Object.fromEntries([...enabled].map((p) => [p, true])), disabledPermissions: Object.fromEntries([...disabled].map((p) => [p, true])) }, roles, role?.id), [bases, enabled, disabled, roles, role]); const nameOf = (id: string) => roles.get(id)?.description || id; const setState = (name: string, state: PermissionState) => { const nextEnabled = new Set(enabled); const nextDisabled = new Set(disabled); nextEnabled.delete(name); nextDisabled.delete(name); if (state === "allow") nextEnabled.add(name); if (state === "deny") nextDisabled.add(name); setEnabled(nextEnabled); setDisabled(nextDisabled); }; const save = async () => { setBusy(true); setError(null); try { if (!role) { if (!description.trim()) { setError(t("A role needs a name.")); return; } const id = await createRole({ description, roleIds: bases, enabled: [...enabled], disabled: [...disabled] }); toast.success(t("Created {name}", { name: description.trim() })); onCreated(id); return; } const patch: Record = { ...setPatch("roleIds", Object.keys(role.roleIds ?? {}), bases), ...setPatch("enabledPermissions", Object.keys(role.enabledPermissions ?? {}), enabled), ...setPatch("disabledPermissions", Object.keys(role.disabledPermissions ?? {}), disabled), }; if ((role.description ?? "") !== description.trim()) patch.description = description.trim(); if (!Object.keys(patch).length) { onClose(); return; } await updateRole(role.id, patch); toast.success(t("Saved {name}", { name: description.trim() })); onChanged(); } catch (err) { setError(describeDirectoryError(err, "role")); } finally { setBusy(false); } }; const title = creating ? t("New role") : role.description || role.id; return ( ); } function BasePicker({ role, roles, bases, setBases, editable }: { role: DirectoryRole | null; roles: ReadonlyMap; bases: string[]; setBases: (b: string[]) => void; editable: boolean; }) { const perms = usePermissions(); const selfId = role?.id ?? null; const options = [...roles.values()].filter((r) => r.id !== selfId); return (
{options.map((r) => { const on = bases.includes(r.id); // Building on a role that already builds on this one would be a loop. const loop = !canBuildOn(selfId, r.id, roles); const grantable = canGrantRole(perms, r.id, roles as Map); return ( ); })} {!options.length && {t("No other roles")}}

{t("A role has every permission of the roles it builds on, apart from any it or they deny.")}

); } function PermissionPicker({ entries, enabled, disabled, base, effective, nameOf, editable, onChange }: { entries: PermissionEntry[]; enabled: Set; disabled: Set; base: { granted: Map; denied: Map }; effective: Set; nameOf: (id: string) => string; editable: boolean; onChange: (name: string, state: PermissionState) => void; }) { const perms = usePermissions(); const [text, setText] = useState(""); const [show, setShow] = useState("all"); const [open, setOpen] = useState>(new Set()); const needle = text.trim().toLowerCase(); const visible = entries.filter((e) => { if (show === "granted" && !effective.has(e.name)) return false; if (show === "set" && !enabled.has(e.name) && !disabled.has(e.name)) return false; return !needle || e.name.toLowerCase().includes(needle) || e.action.toLowerCase().includes(needle) || e.category.toLowerCase().includes(needle); }); const groups = useMemo(() => { const out = new Map(); for (const e of entries) { const g = out.get(e.categoryKey) ?? { label: e.category, items: [], total: 0, granted: 0 }; g.total += 1; if (effective.has(e.name)) g.granted += 1; out.set(e.categoryKey, g); } for (const e of visible) out.get(e.categoryKey)!.items.push(e); return [...out.entries()].filter(([, g]) => g.items.length); }, [entries, visible, effective]); const expandAll = Boolean(needle) || show !== "all"; return (
{groups.length === 0 &&

{t("No permissions match")}

} {groups.map(([key, g]) => { const isOpen = expandAll || open.has(key); return (
{isOpen && (
    {g.items.map((e) => { const own: PermissionState = enabled.has(e.name) ? "allow" : disabled.has(e.name) ? "deny" : "none"; const from = base.denied.get(e.name) ?? base.granted.get(e.name); const note = base.denied.has(e.name) ? t("Denied by {role}", { role: nameOf(base.denied.get(e.name)!) }) : from ? t("Granted by {role}", { role: nameOf(from) }) : null; // Stalwart refuses a grant the caller does not hold; a denial takes nothing it needs to check. const cannotAllow = !perms.has(e.name) && own !== "allow"; return (
  • {e.action}
    {e.name} {note && own === "none" ? ` · ${note}` : ""}
  • ); })}
)}
); })}

{t("A denial wins over anything allowed, here or on a role this one builds on. You can only allow permissions you hold yourself.")}

); } function DeleteRole({ role, blocked, onDeleted }: { role: DirectoryRole; 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 name = role.description || role.id; return ( <>

{t("Delete")}

{blocked ?? t("Accounts, groups and other roles that use it must be moved off it first.")}

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

{t("It can't be undone.")}

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

{error}

}
); }