import { useEffect, useState, useRef, useMemo } from "react"; import type { ReactElement } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Loader2, ChevronDown, ChevronRight } from "lucide-react"; import { Modal, FormField, PrimaryButton, SecondaryButton, } from "@/components/shared"; import type { Role, UpdateRoleRequest } from "@/types/role"; import { useAppSelector } from "@/hooks/redux-hooks"; // Utility function to generate code from name const generateCodeFromName = (name: string): string => { return name .toLowerCase() .trim() .replace(/[^a-z0-9\s]/g, "") // Remove special characters except spaces .replace(/\s+/g, "_") // Replace spaces with underscores .replace(/_+/g, "_") // Replace multiple underscores with single underscore .replace(/^_|_$/g, ""); // Remove leading/trailing underscores }; // All available resources const ALL_RESOURCES = [ "users", // 'tenants', "roles", "permissions", // 'user_roles', // 'user_tenants', // 'sessions', // 'api_keys', // 'api_key_permissions', "projects", "document", "audit", "security", "workflow", "training", "capa", "supplier", "reports", "notifications", "files", "settings", // 'modules', "audit_logs", // 'event_logs', // 'health_history', "qms_connections", "qms_sync_jobs", "qms_sync_conflicts", "qms_entity_mappings", "ai", "qms", ]; // All available actions const ALL_ACTIONS = ["create", "read", "update", "delete"]; // Validation schema const editRoleSchema = z.object({ name: z.string().min(1, "Role name is required"), code: z .string() .min(1, "Code is required") .regex( /^[a-z]+(_[a-z]+)*$/, "Code must be lowercase and use '_' for separation (e.g. abc_def)", ), description: z.string().min(1, "Description is required"), permissions: z .array( z.object({ resource: z.string(), action: z.string(), }), ) .optional() .nullable(), }); type EditRoleFormData = z.infer; interface EditRoleModalProps { isOpen: boolean; onClose: () => void; roleId: string | null; onLoadRole: (id: string) => Promise; onSubmit: (id: string, data: UpdateRoleRequest) => Promise; isLoading?: boolean; defaultTenantId?: string; // If provided, automatically include tenant_id in request body } export const EditRoleModal = ({ isOpen, onClose, roleId, onLoadRole, onSubmit, isLoading = false, defaultTenantId, }: EditRoleModalProps): ReactElement | null => { const [isLoadingRole, setIsLoadingRole] = useState(false); const [loadError, setLoadError] = useState(null); const loadedRoleIdRef = useRef(null); const permissions = useAppSelector((state) => state.auth.permissions); const roles = useAppSelector((state) => state.auth.roles); const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId); const isSuperAdmin = roles.includes("super_admin"); const [selectedPermissions, setSelectedPermissions] = useState< Array<{ resource: string; action: string }> >([]); const [expandedResources, setExpandedResources] = useState>( new Set(), ); const { register, handleSubmit, setValue, watch, reset, setError, clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(editRoleSchema), defaultValues: { permissions: [], }, }); const nameValue = watch("name"); // Auto-generate code from name useEffect(() => { if (nameValue) { const generatedCode = generateCodeFromName(nameValue); setValue("code", generatedCode, { shouldValidate: true }); } }, [nameValue, setValue]); // Build available resources and actions based on user permissions const availableResourcesAndActions = useMemo(() => { const resourceMap = new Map>(); permissions.forEach((perm) => { const { resource, action } = perm; if (resource === "*") { // If resource is *, show all resources ALL_RESOURCES.forEach((res) => { if (!resourceMap.has(res)) { resourceMap.set(res, new Set()); } const actions = resourceMap.get(res)!; if (action === "*") { // If action is also *, add all actions ALL_ACTIONS.forEach((act) => actions.add(act)); } else { actions.add(action); } }); } else { // Specific resource if (!resourceMap.has(resource)) { resourceMap.set(resource, new Set()); } const actions = resourceMap.get(resource)!; if (action === "*") { // If action is *, add all actions for this resource ALL_ACTIONS.forEach((act) => actions.add(act)); } else { actions.add(action); } } }); return resourceMap; }, [permissions]); // Check if a resource has any selected actions const hasSelectedActions = ( resource: string, actions: Set, ): boolean => { return Array.from(actions).some((action) => { return selectedPermissions.some((p) => { // Check for exact match if (p.resource === resource && p.action === action) return true; // Check for wildcard resource with exact action if (p.resource === "*" && p.action === action) return true; // Check for exact resource with wildcard action if (p.resource === resource && p.action === "*") return true; // Check for wildcard resource with wildcard action if (p.resource === "*" && p.action === "*") return true; return false; }); }); }; // Toggle resource expansion const toggleResource = (resource: string) => { setExpandedResources((prev) => { const newSet = new Set(prev); if (newSet.has(resource)) { newSet.delete(resource); } else { newSet.add(resource); } return newSet; }); }; // Handle permission checkbox change const handlePermissionChange = ( resource: string, action: string, checked: boolean, ) => { setSelectedPermissions((prev) => { const newPerms = [...prev]; if (checked) { // Add permission if not already exists if ( !newPerms.some((p) => p.resource === resource && p.action === action) ) { newPerms.push({ resource, action }); } } else { // Remove permission return newPerms.filter( (p) => !(p.resource === resource && p.action === action), ); } return newPerms; }); }; // Update form value when permissions change useEffect(() => { setValue( "permissions", selectedPermissions.length > 0 ? selectedPermissions : [], ); }, [selectedPermissions, setValue]); // Expand resources that have selected permissions when role is loaded useEffect(() => { if ( selectedPermissions.length > 0 && availableResourcesAndActions.size > 0 ) { const resourcesWithPermissions = new Set(); selectedPermissions.forEach((perm) => { if (perm.resource === "*") { // If wildcard resource, expand all available resources availableResourcesAndActions.forEach((_, resource) => { resourcesWithPermissions.add(resource); }); } else if (availableResourcesAndActions.has(perm.resource)) { // Only expand if resource exists in available resources resourcesWithPermissions.add(perm.resource); } }); // Only update if we have resources to expand and they're not already expanded if (resourcesWithPermissions.size > 0) { setExpandedResources((prev) => { const newSet = new Set(prev); resourcesWithPermissions.forEach((resource) => { if (!newSet.has(resource)) { newSet.add(resource); } }); return newSet; }); } } }, [selectedPermissions, availableResourcesAndActions]); // Load role data when modal opens - only load once per roleId useEffect(() => { if (isOpen && roleId) { // Only load if this is a new roleId or modal was closed and reopened if (loadedRoleIdRef.current !== roleId) { const loadRole = async (): Promise => { try { setIsLoadingRole(true); setLoadError(null); clearErrors(); const role = await onLoadRole(roleId); loadedRoleIdRef.current = roleId; // Set permissions (always set, even if empty array) const rolePermissions = role.permissions || []; setSelectedPermissions(rolePermissions); setValue("permissions", rolePermissions); // Expand resources that have selected permissions // This will be handled by useEffect after availableResourcesAndActions is computed reset({ name: role.name, code: role.code, description: role.description || "", permissions: rolePermissions, }); } catch (err: any) { setLoadError( err?.response?.data?.error?.message || "Failed to load role details", ); } finally { setIsLoadingRole(false); } }; loadRole(); } } else if (!isOpen) { // Only reset when modal is closed loadedRoleIdRef.current = null; setSelectedPermissions([]); reset({ name: "", code: "", description: "", permissions: [], }); setLoadError(null); clearErrors(); } }, [ isOpen, roleId, onLoadRole, reset, clearErrors, setValue, isSuperAdmin, defaultTenantId, tenantIdFromAuth, ]); const handleFormSubmit = async (data: EditRoleFormData): Promise => { if (!roleId) return; clearErrors(); try { const submitData = { ...data, // For super_admin, always include tenant_id if defaultTenantId is provided tenant_id: isSuperAdmin ? defaultTenantId || undefined : defaultTenantId || undefined, permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined, }; await onSubmit(roleId, submitData as UpdateRoleRequest); // Only reset form on success - this will be handled by parent closing modal } catch (error: any) { // Don't reset form on error - keep the form data and show errors // Handle validation errors from API if ( error?.response?.data?.details && Array.isArray(error.response.data.details) ) { const validationErrors = error.response.data.details; validationErrors.forEach( (detail: { path: string; message: string }) => { if ( detail.path === "name" || detail.path === "code" || detail.path === "description" || detail.path === "permissions" ) { setError(detail.path as keyof EditRoleFormData, { type: "server", message: detail.message, }); } }, ); } else { // Handle general errors const errorObj = error?.response?.data?.error; const errorMessage = (typeof errorObj === "object" && errorObj !== null && "message" in errorObj ? errorObj.message : null) || (typeof errorObj === "string" ? errorObj : null) || error?.response?.data?.message || error?.message || "Failed to update role. Please try again."; setError("root", { type: "server", message: typeof errorMessage === "string" ? errorMessage : "Failed to update role. Please try again.", }); } // Re-throw error to prevent form from thinking it succeeded throw error; } }; return ( Cancel {isLoading ? "Updating..." : "Update Role"} } > {isLoadingRole && (
)} {/* General Error Display - Always visible */} {errors.root && (

{errors.root.message}

)} {loadError && (

{loadError}

)} {!isLoadingRole && (
{/* Role Name and Role Code Row */}
{/* Description */} {/* Permissions Section */}
{errors.permissions && (

{errors.permissions.message}

)}
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (

No permissions available

) : (
{Array.from(availableResourcesAndActions.entries()).map( ([resource, actions]) => { const isExpanded = expandedResources.has(resource); const hasSelected = hasSelectedActions(resource, actions); return (
{isExpanded && (
{Array.from(actions).map((action) => { const isChecked = selectedPermissions.some( (p) => { // Check for exact match if ( p.resource === resource && p.action === action ) { return true; } // Check for wildcard resource with exact action if ( p.resource === "*" && p.action === action ) { return true; } // Check for exact resource with wildcard action if ( p.resource === resource && p.action === "*" ) { return true; } // Check for wildcard resource with wildcard action if ( p.resource === "*" && p.action === "*" ) { return true; } return false; }, ); return ( ); })}
)}
); }, )}
)}
)}
); };