import { useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin"; import { Button } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { loadDeltaRows } from "./utils/deltaRows"; const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true }; export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise;}) { const [roles, setRoles] = useState([]); const [permissions, setPermissions] = useState([]); const rolesRef = useRef([]); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); const [draft, setDraft] = useState(emptyDraft); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [deleting, setDeleting] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const dirty = editing !== null && draftKey(draft) !== savedDraftKey; useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: closeEditor }); async function load() { setLoading(true); setError(""); try { const [nextRoles, nextPermissions] = await Promise.all([ loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles), fetchPermissionCatalog(settings)] ); rolesRef.current = nextRoles; setRoles(nextRoles); setPermissions(nextPermissions.filter((permission) => permission.level === "tenant")); } catch (err) {setError(adminErrorMessage(err));} finally {setLoading(false);} } useEffect(() => { rolesRef.current = []; resetDeltaWatermark(); void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]); const permissionGroups = useMemo(() => { const groups = new Map(); for (const permission of permissions) groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]); return Array.from(groups.entries()); }, [permissions]); function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");} function openEdit(role: RoleSummary) { if (role.is_builtin || role.system_template_id) return; const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable }; setDraft(nextDraft); setSavedDraftKey(draftKey(nextDraft)); setEditing(role); setError(""); } function closeEditor() { setEditing(null); setDraft(emptyDraft); setSavedDraftKey(draftKey(emptyDraft)); } function togglePermission(scope: string, checked: boolean) { const next = new Set(draft.permissions); if (checked) next.add(scope);else next.delete(scope); setDraft({ ...draft, permissions: Array.from(next) }); } async function save(): Promise { setBusy(true); setError(""); try { if (editing === "new") { await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions }); setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name })); } else if (editing) { await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable }); setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name })); } setEditing(null); await onAuthRefresh(); await load(); return true; } catch (err) {setError(adminErrorMessage(err));return false;} finally {setBusy(false);} } async function remove() { if (!deleting) return; setBusy(true); setError(""); try { await deleteRole(settings, deleting.id); setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name })); setDeleting(null); await onAuthRefresh(); await load(); } catch (err) {setError(adminErrorMessage(err));} finally {setBusy(false);} } const columns = useMemo[]>(() => [ { id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) =>
{row.name}
{row.slug} {row.system_template_id && i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}}
}, { id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) }, { id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` }, { id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => }, { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) =>
} onClick={() => setViewing(row)} /> } onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} /> } variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
}], [canDefine, permissions]); return ( <> } variant="primary" onClick={openCreate} disabled={!canDefine} />}>
row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" />
!busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}>
setDraft({ ...draft, name: event.target.value })} /> setDraft({ ...draft, slug: event.target.value })} />