"use client"; import { RoundPageContainer } from "@/components"; import { cn } from "@/lib/utils"; import { Loader2Icon } from "lucide-react"; import { useTranslations } from "next-intl"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRbacContext } from "../contexts/RbacContext"; import { ACTION_TYPES, ActionType, PermissionValue, type PermToken, type RbacModuleBlock } from "../data/RbacTypes"; import RbacPermissionCell from "./RbacPermissionCell"; import { RbacPermissionPicker } from "./RbacPermissionPicker"; // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- function findToken(tokens: PermToken[] | undefined, action: ActionType): PermToken | undefined { if (!tokens) return undefined; return tokens.find((t) => t.action === action); } function cellValue(tokens: PermToken[] | undefined, action: ActionType): PermissionValue | undefined { const tok = findToken(tokens, action); if (!tok) return undefined; return tok.scope; } // --------------------------------------------------------------------------- // Picker target state // --------------------------------------------------------------------------- interface ActivePicker { moduleId: string; rowKey: string; // "default" or a role UUID action: ActionType; isRoleColumn: boolean; anchor: HTMLElement; } // --------------------------------------------------------------------------- // Cell trigger — pure display + a click handler that opens the global picker. // Memoised so unchanged cells don't re-render. // --------------------------------------------------------------------------- interface CellButtonProps { moduleId: string; rowKey: string; action: ActionType; tokens: PermToken[] | undefined; isRoleColumn: boolean; onOpen: (picker: ActivePicker) => void; } const CellButton = memo(function CellButton({ moduleId, rowKey, action, tokens, isRoleColumn, onOpen, }: CellButtonProps) { const ref = useRef(null); const value = cellValue(tokens, action); const handleClick = useCallback(() => { if (!ref.current) return; onOpen({ moduleId, rowKey, action, isRoleColumn, anchor: ref.current }); }, [onOpen, moduleId, rowKey, action, isRoleColumn]); return (
); }); // --------------------------------------------------------------------------- // Single-module editor — renders exactly one module's table. // Memoised on its inputs so sidebar re-renders (e.g. new selectedModuleId) // don't re-render the editor when the editor's module-level data hasn't // changed. // --------------------------------------------------------------------------- const ACTION_LABELS: Record = { read: "Read", create: "Create", update: "Update", delete: "Delete", }; interface ModuleEditorProps { moduleId: string; block: RbacModuleBlock; moduleLabel: string; roleIds: string[]; roleNames?: Record; onOpenPicker: (picker: ActivePicker) => void; } const ModuleEditor = memo(function ModuleEditor({ moduleId, block, moduleLabel, roleIds, roleNames, onOpenPicker, }: ModuleEditorProps) { const t = useTranslations(); const defaultTokens = block.default ?? []; return (

{moduleLabel}

{moduleId}
{ACTION_TYPES.map((action) => ( ))} {/* Defaults row */} {ACTION_TYPES.map((action) => ( ))} {/* Role rows */} {roleIds.map((roleId) => { const roleTokens = (block as Record)[roleId]; const roleLabel = roleNames?.[roleId] ?? roleId; return ( {ACTION_TYPES.map((action) => ( ))} ); })}
{t("rbac.role")} {ACTION_LABELS[action]}
{t("rbac.defaults")}
{roleLabel}
); }); // --------------------------------------------------------------------------- // Container — sidebar (module list) + detail (single module editor) + one // global picker. // --------------------------------------------------------------------------- export default function RbacContainer() { const t = useTranslations(); const { matrix, modulePaths, loading, error, roleNames, moduleNames, updateCell, clearCell } = useRbacContext(); // Which module is visible in the right pane. const [selectedModuleId, setSelectedModuleId] = useState(null); // Which cell the single global picker is currently anchored to. const [activePicker, setActivePicker] = useState(null); const openPicker = useCallback((picker: ActivePicker) => { setActivePicker(picker); }, []); const closePicker = useCallback(() => { setActivePicker(null); }, []); const handleSelectModule = useCallback((id: string) => { setSelectedModuleId(id); setActivePicker(null); // stale anchor once we switch modules }, []); /** Module IDs sorted by display name (fallback to UUID). */ const sortedModuleIds = useMemo(() => { if (!matrix) return []; return Object.keys(matrix).sort((a, b) => (moduleNames?.[a] ?? a).localeCompare(moduleNames?.[b] ?? b)); }, [matrix, moduleNames]); /** * All role IDs to render as rows. When `roleNames` is provided (normal app * path), use its full key set so every role appears — not just roles that * already have an entry in the matrix. Sorted by display name. */ const roleIds = useMemo(() => { if (roleNames) { return Object.keys(roleNames).sort((a, b) => (roleNames[a] ?? a).localeCompare(roleNames[b] ?? b)); } if (!matrix) return []; const set = new Set(); for (const moduleId of Object.keys(matrix)) { for (const key of Object.keys(matrix[moduleId] ?? {})) { if (key !== "default") set.add(key); } } return Array.from(set).sort(); }, [matrix, roleNames]); // Auto-select the first module once the matrix loads. useEffect(() => { if (!selectedModuleId && sortedModuleIds.length > 0) { setSelectedModuleId(sortedModuleIds[0]); } }, [selectedModuleId, sortedModuleIds]); // --- Picker-driven values --------------------------------------------------- const activeValue = useMemo(() => { if (!activePicker || !matrix) return undefined; const block = matrix[activePicker.moduleId]; if (!block) return undefined; const tokens = activePicker.rowKey === "default" ? block.default : (block as Record)[activePicker.rowKey]; return cellValue(tokens, activePicker.action); }, [activePicker, matrix]); const activeSegments = useMemo(() => { if (!activePicker) return []; return (modulePaths[activePicker.moduleId] as string[] | undefined) ?? []; }, [activePicker, modulePaths]); // Update-only: whether to ALSO close the picker is the picker's decision // (quick true/false + "inherit" close via the picker's own onClose; checkbox // toggles do NOT close so users can pick multiple relationship paths). const handleSetValue = useCallback( (value: PermissionValue) => { if (!activePicker) return; updateCell(activePicker.moduleId, activePicker.rowKey, activePicker.action, value); }, [activePicker, updateCell], ); const handleClear = useCallback(() => { if (!activePicker || !activePicker.isRoleColumn) return; clearCell(activePicker.moduleId, activePicker.rowKey, activePicker.action); }, [activePicker, clearCell]); // --- Transient-state returns ------------------------------------------------ if (loading) { return (
); } if (error) { return (

{error}

); } if (!matrix) return null; const selectedBlock: RbacModuleBlock | undefined = selectedModuleId ? matrix[selectedModuleId] : undefined; return (
{/* Sidebar: module list */} {/* Detail: one module's editor */}
{selectedModuleId && selectedBlock ? ( ) : (

{t("rbac.select_module_prompt")}

)}
{/* One global picker for the whole container. */}
); } export { RbacContainer };