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, MultiselectPaginatedSelect } from '@/components/shared'; import type { Role, UpdateRoleRequest } from '@/types/role'; import { useAppSelector } from '@/hooks/redux-hooks'; import { moduleService } from '@/services/module-service'; // 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'), modules: z.array(z.string().uuid()).optional().nullable(), 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 [selectedAvailableModules, setSelectedAvailableModules] = useState([]); const [selectedPermissions, setSelectedPermissions] = useState>([]); const [initialAvailableModuleOptions, setInitialAvailableModuleOptions] = useState>([]); const [expandedResources, setExpandedResources] = useState>(new Set()); const { register, handleSubmit, setValue, watch, reset, setError, clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(editRoleSchema), defaultValues: { modules: [], permissions: [], }, }); const nameValue = watch('name'); // Auto-generate code from name useEffect(() => { if (nameValue) { const generatedCode = generateCodeFromName(nameValue); setValue('code', generatedCode, { shouldValidate: true }); } }, [nameValue, setValue]); // Load available modules from /modules/available endpoint // For super_admin, send tenant_id if defaultTenantId is provided // For tenant users, send tenant_id from auth state const loadAvailableModules = async (page: number, limit: number) => { const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth; const response = await moduleService.getAvailable(page, limit, tenantId); return { options: response.data.map((module) => ({ value: module.id, label: module.name, })), pagination: response.pagination, }; }; // 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]); // Update form value when available modules change useEffect(() => { setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []); }, [selectedAvailableModules, 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; // Extract modules and permissions from role const roleModules = role.modules || []; const rolePermissions = role.permissions || []; // Set available modules if exists if (roleModules.length > 0) { setSelectedAvailableModules(roleModules); setValue('modules', roleModules); // Load module names from available modules API // Use tenant_id from auth for tenant users, or defaultTenantId for super_admin const loadModuleNames = async () => { try { const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth; // Load first page of available modules to get module names const availableModulesResponse = await moduleService.getAvailable(1, 100, tenantId); // Map role modules to options from available modules const moduleOptions = roleModules .map((moduleId: string) => { const module = availableModulesResponse.data.find((m) => m.id === moduleId); if (module) { return { value: moduleId, label: module.name, }; } return null; }) .filter((opt) => opt !== null) as Array<{ value: string; label: string }>; setInitialAvailableModuleOptions(moduleOptions); } catch (err) { console.warn('Failed to load available module names:', err); // Fallback: try to load individual modules if available modules endpoint fails try { const moduleOptionsPromises = roleModules.map(async (moduleId: string) => { try { const moduleResponse = await moduleService.getById(moduleId); return { value: moduleId, label: moduleResponse.data.name, }; } catch { return null; } }); const moduleOptions = (await Promise.all(moduleOptionsPromises)).filter( (opt) => opt !== null ) as Array<{ value: string; label: string }>; setInitialAvailableModuleOptions(moduleOptions); } catch (fallbackErr) { console.warn('Fallback loading also failed:', fallbackErr); } } }; loadModuleNames(); } else { setSelectedAvailableModules([]); setInitialAvailableModuleOptions([]); } // Set permissions (always set, even if empty array) 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 || '', modules: roleModules, 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; setSelectedAvailableModules([]); setSelectedPermissions([]); setInitialAvailableModuleOptions([]); reset({ name: '', code: '', description: '', modules: [], 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), // Include modules from available modules endpoint modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : 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 === 'modules' || 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 */} {/* Available Modules Selection */} { setSelectedAvailableModules(values); setValue('modules', values.length > 0 ? values : []); }} onLoadOptions={loadAvailableModules} initialOptions={initialAvailableModuleOptions} error={errors.modules?.message} /> {/* 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 ( ); })}
)}
); })}
)}
)}
); };