import { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from './useApi'; import { getUserDataCache } from '../utils/userCache'; import { usePermissions, type UserPermissions } from './usePermissions'; import { fetchRoles as fetchRolesApi, fetchRoleById as fetchRoleByIdApi, createRole as createRoleApi, updateRole as updateRoleApi, deleteRole as deleteRoleApi, type Role, type RoleUpdateData, type PaginationParams } from '../api/roleApi'; import { fetchAttributes } from '../api/attributesApi'; import type { AttributeDefinition } from '../api/attributesApi'; import { isCheckboxType, isSelectType, isMultiselectType, isDateTimeType, isTextareaType, type AttributeType } from '../utils/attributeTypeMapper'; // Re-export types for backward compatibility export type { Role, RoleUpdateData, AttributeDefinition, PaginationParams }; // Helper function to detect TextMultilingual objects // TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string } const isTextMultilingual = (value: any): boolean => { if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) { return false; } // Check if it has 'en' property (required) and optionally other language codes return 'en' in value && typeof value.en === 'string'; }; // Helper function to check if a field name suggests it's a multilingual field // Only specific fields should be multilingual, not fields that just contain these words const isMultilingualFieldName = (fieldName: string): boolean => { const lowerFieldName = fieldName.toLowerCase(); // Exact matches for multilingual fields const exactMultilingualFields = ['description']; // Fields that end with these patterns (but not roleLabel, etc.) const multilingualPatterns = [ /^description$/i, /^label$/i, // Only exact "label", not "roleLabel" /^title$/i, // Only exact "title" /^name$/i // Only exact "name", not field names containing "name" ]; // Check exact matches first if (exactMultilingualFields.includes(lowerFieldName)) { return true; } // Check patterns - but exclude fields like "roleLabel" which should be strings const excludedFields = ['rolelabel', 'role_label', 'rolename', 'role_name', 'username', 'user_name']; if (excludedFields.includes(lowerFieldName)) { return false; } // Check if it matches multilingual patterns (exact match, not contains) return multilingualPatterns.some(pattern => pattern.test(fieldName)); }; // RBAC roles hook (list, update, delete) - following mandates pattern export function useRbacRoles() { const [roles, setRoles] = useState([]); const [attributes, setAttributes] = useState([]); const [permissions, setPermissions] = useState(null); const [pagination, setPagination] = useState<{ currentPage: number; pageSize: number; totalItems: number; totalPages: number; } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); // Fetch attributes from backend const fetchAttributesData = useCallback(async () => { try { const attrs = await fetchAttributes(request, 'Role'); setAttributes(attrs); return attrs; } catch (error: any) { // Don't log 429 errors as errors (they're rate limit warnings) if (error.response?.status === 429) { console.warn('Rate limit exceeded while fetching role attributes. Please wait.'); } else if (error.response?.status !== 401) { // Only log non-auth errors (401 is expected when not logged in) console.error('Error fetching attributes:', error); } setAttributes([]); return []; } }, [request]); // Fetch permissions from backend const fetchPermissions = useCallback(async () => { try { const perms = await checkPermission('DATA', 'Role'); setPermissions(perms); return perms; } catch (error: any) { console.error('Error fetching permissions:', error); const defaultPerms: UserPermissions = { view: false, read: 'n', create: 'n', update: 'n', delete: 'n', }; setPermissions(defaultPerms); return defaultPerms; } }, [checkPermission]); const fetchRoles = useCallback(async (params?: PaginationParams) => { try { const requestParams: any = {}; // Build pagination object if provided if (params) { const paginationObj: any = {}; if (params.page !== undefined) paginationObj.page = params.page; if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } } const data = await fetchRolesApi(request, params); // Handle paginated response if (data && typeof data === 'object' && 'items' in data) { const items = Array.isArray(data.items) ? data.items : []; setRoles(items); if (data.pagination) { setPagination(data.pagination); } } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setRoles(items); setPagination(null); } } catch (error: any) { // Error is already handled by useApiRequest setRoles([]); setPagination(null); } }, [request]); // Optimistically remove a role from the local state const removeOptimistically = (roleId: string) => { setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId)); }; // Optimistically update a role in the local state const updateOptimistically = (roleId: string, updateData: Partial) => { setRoles(prevRoles => prevRoles.map(role => role.id === roleId ? { ...role, ...updateData } : role ) ); }; // Fetch a single role by ID const fetchRoleById = useCallback(async (roleId: string): Promise => { return await fetchRoleByIdApi(request, roleId); }, [request]); // Generate edit fields from attributes dynamically using attributeTypeMapper utilities const generateEditFieldsFromAttributes = useCallback((): Array<{ key: string; label: string; type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; editable?: boolean; required?: boolean; validator?: (value: any) => string | null; minRows?: number; maxRows?: number; options?: Array<{ value: string | number; label: string }>; optionsReference?: string; // For options that need to be fetched (e.g., "user.role") }> => { if (!attributes || attributes.length === 0) { return []; } const editableFields = attributes .filter(attr => { // Filter out non-editable fields based on readonly/editable flags if (attr.readonly === true || attr.editable === false) { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { // Map backend attribute type to form field type using attributeTypeMapper utilities let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined = undefined; let optionsReference: string | undefined = undefined; const attrType = attr.type as AttributeType; // Use attributeTypeMapper utilities to determine field type if (isCheckboxType(attrType)) { fieldType = 'boolean'; } else if (attrType === 'email') { fieldType = 'email'; } else if (isDateTimeType(attrType)) { fieldType = 'date'; } else if (isSelectType(attrType)) { fieldType = 'enum'; // Handle options - can be array or string reference const attrOptions = (attr as any).options; if (Array.isArray(attrOptions)) { options = attrOptions.map((opt: any) => { const labelValue = typeof opt.label === 'string' ? opt.label : opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value); return { value: opt.value, label: labelValue }; }); } else if (typeof attrOptions === 'string') { // Options reference (e.g., "user.role", "auth.authority") optionsReference = attrOptions; } } else if (isMultiselectType(attrType)) { fieldType = 'multiselect'; // Handle options - can be array or string reference const attrOptions = (attr as any).options; if (Array.isArray(attrOptions)) { options = attrOptions.map((opt: any) => { const labelValue = typeof opt.label === 'string' ? opt.label : opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value); return { value: opt.value, label: labelValue }; }); } else if (typeof attrOptions === 'string') { // Options reference (e.g., "user.role", "auth.authority") optionsReference = attrOptions; } } else if (isTextareaType(attrType)) { fieldType = 'textarea'; } else if (attrType === 'readonly') { fieldType = 'readonly'; } else { fieldType = 'string'; } // Define validators and required fields let required = attr.required === true; let validator: ((value: any) => string | null) | undefined = undefined; let minRows: number | undefined = undefined; let maxRows: number | undefined = undefined; // Email validation if (fieldType === 'email') { validator = (value: any) => { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) { return 'Email cannot be empty'; } if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Invalid email format'; } return null; }; } // Textarea settings else if (fieldType === 'textarea') { minRows = 4; maxRows = 8; if (attr.name.toLowerCase().includes('content')) { minRows = 6; maxRows = 12; } } // Multiselect validation else if (fieldType === 'multiselect' && required) { validator = (value: any[]) => { if (!value || !Array.isArray(value) || value.length === 0) { return `${attr.label} is required`; } return null; }; } return { key: attr.name, label: attr.label || attr.name, type: fieldType, editable: (attr as any).editable !== false && (attr as any).readonly !== true, required, validator, minRows, maxRows, options, optionsReference }; }); return editableFields; }, [attributes]); // Generate create fields from attributes dynamically using attributeTypeMapper utilities const generateCreateFieldsFromAttributes = useCallback((): Array<{ key: string; label: string; type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; required?: boolean; validator?: (value: any) => string | null; minRows?: number; maxRows?: number; options?: Array<{ value: string | number; label: string }>; optionsReference?: string; placeholder?: string; }> => { if (!attributes || attributes.length === 0) { return []; } const createFields = attributes .filter(attr => { // Filter out non-editable fields and auto-generated fields for create forms if (attr.readonly === true || attr.editable === false) { return false; } // Filter out ID fields and other auto-generated fields const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { // Map backend attribute type to form field type using attributeTypeMapper utilities let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined = undefined; let optionsReference: string | undefined = undefined; const attrType = attr.type as AttributeType; // Use attributeTypeMapper utilities to determine field type if (isCheckboxType(attrType)) { fieldType = 'boolean'; } else if (attrType === 'email') { fieldType = 'email'; } else if (isDateTimeType(attrType)) { fieldType = 'date'; } else if (isSelectType(attrType)) { fieldType = 'enum'; const attrOptions = (attr as any).options; if (Array.isArray(attrOptions)) { options = attrOptions.map((opt: any) => { const labelValue = typeof opt.label === 'string' ? opt.label : opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value); return { value: opt.value, label: labelValue }; }); } else if (typeof attrOptions === 'string') { optionsReference = attrOptions; } } else if (isMultiselectType(attrType)) { fieldType = 'multiselect'; const attrOptions = (attr as any).options; if (Array.isArray(attrOptions)) { options = attrOptions.map((opt: any) => { const labelValue = typeof opt.label === 'string' ? opt.label : opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value); return { value: opt.value, label: labelValue }; }); } else if (typeof attrOptions === 'string') { optionsReference = attrOptions; } } else if (isTextareaType(attrType)) { fieldType = 'textarea'; } else { fieldType = 'string'; } // Define validators and required fields let required = attr.required === true; let validator: ((value: any) => string | null) | undefined = undefined; let minRows: number | undefined = undefined; let maxRows: number | undefined = undefined; // Email validation if (fieldType === 'email') { validator = (value: any) => { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) { return 'Email cannot be empty'; } if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Invalid email format'; } return null; }; } // Textarea settings else if (fieldType === 'textarea') { minRows = 4; maxRows = 8; if (attr.name.toLowerCase().includes('content')) { minRows = 6; maxRows = 12; } } // Multiselect validation else if (fieldType === 'multiselect' && required) { validator = (value: any[]) => { if (!value || !Array.isArray(value) || value.length === 0) { return `${attr.label} is required`; } return null; }; } // String validation for required fields else if (fieldType === 'string' && required) { validator = (value: any) => { // Check if this is a multilingual field (TextMultilingual object) if (isMultilingualFieldName(attr.name)) { // Handle TextMultilingual object if (isTextMultilingual(value)) { if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') { return `${attr.label} (English) is required`; } return null; } // If it's a multilingual field but value is not yet a TextMultilingual object, // check if it's an empty object or needs initialization if (value && typeof value === 'object' && !Array.isArray(value)) { // Empty object or object without 'en' property if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') { return `${attr.label} (English) is required`; } return null; } // If it's a string, that's also valid (will be converted to TextMultilingual) if (typeof value === 'string' && value.trim() !== '') { return null; } // Empty or invalid value return `${attr.label} (English) is required`; } // Regular string validation for non-multilingual fields if (typeof value !== 'string' || !value || value.trim() === '') { return `${attr.label} is required`; } return null; }; } return { key: attr.name, label: attr.label || attr.name, type: fieldType, required, validator, minRows, maxRows, options, optionsReference, placeholder: (attr as any).placeholder }; }); return createFields; }, [attributes]); // Ensure attributes are loaded - can be called by EditActionButton const ensureAttributesLoaded = useCallback(async () => { // Don't fetch attributes if user is not authenticated (prevents 401 errors) const currentUser = getUserDataCache(); if (!currentUser) { return []; } if (attributes && attributes.length > 0) { return attributes; } const fetchedAttributes = await fetchAttributesData(); return fetchedAttributes; }, [attributes, fetchAttributesData]); // Fetch attributes and permissions on mount (only if user is authenticated) useEffect(() => { const currentUser = getUserDataCache(); if (currentUser) { fetchAttributesData(); fetchPermissions(); } }, [fetchAttributesData, fetchPermissions]); // Initial fetch useEffect(() => { fetchRoles(); }, [fetchRoles]); return { data: roles, loading, error, refetch: fetchRoles, removeOptimistically, updateOptimistically, attributes, permissions, pagination, fetchRoleById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, ensureAttributesLoaded }; } // Role operations hook export function useRbacRoleOperations() { const [deletingRoles, setDeletingRoles] = useState>(new Set()); const [editingRoles, setEditingRoles] = useState>(new Set()); const [creatingRole, setCreatingRole] = useState(false); const { request, isLoading } = useApiRequest(); const [deleteError, setDeleteError] = useState(null); const [createError, setCreateError] = useState(null); const [updateError, setUpdateError] = useState(null); const handleRoleDelete = async (roleId: string) => { setDeleteError(null); setDeletingRoles(prev => new Set(prev).add(roleId)); try { await deleteRoleApi(request, roleId); await new Promise(resolve => setTimeout(resolve, 300)); return true; } catch (error: any) { setDeleteError(error.message); return false; } finally { setDeletingRoles(prev => { const newSet = new Set(prev); newSet.delete(roleId); return newSet; }); } }; const handleRoleCreate = async (roleData: Partial) => { console.log('🟢 handleRoleCreate - Complete input structure:', { roleData, roleDataType: typeof roleData, roleDataIsArray: Array.isArray(roleData), roleDataIsNull: roleData === null, roleDataIsUndefined: roleData === undefined, roleDataKeys: roleData ? Object.keys(roleData) : [], roleDataEntries: roleData ? Object.entries(roleData).map(([key, value]) => ({ key, value, valueType: typeof value, valueIsObject: typeof value === 'object' && value !== null && !Array.isArray(value), valueIsArray: Array.isArray(value), valueIsNull: value === null, valueIsUndefined: value === undefined, valueStringified: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) })) : [], roleDataStringified: JSON.stringify(roleData, null, 2), roleDataStringifiedCompact: JSON.stringify(roleData) }); setCreateError(null); setCreatingRole(true); try { const newRole = await createRoleApi(request, roleData); console.log('✅ handleRoleCreate - Success, newRole:', newRole); return { success: true, roleData: newRole }; } catch (error: any) { console.error('❌ handleRoleCreate - Error:', error); console.error('❌ handleRoleCreate - Complete error details:', { error, message: error.message, response: error.response, responseData: error.response?.data, responseStatus: error.response?.status, responseStatusText: error.response?.statusText, responseHeaders: error.response?.headers, request: error.request, config: error.config, stack: error.stack }); setCreateError(error.message); return { success: false, error: error.message }; } finally { setCreatingRole(false); } }; const handleRoleUpdate = async (roleId: string, updateData: RoleUpdateData, _originalData?: any) => { setUpdateError(null); setEditingRoles(prev => new Set(prev).add(roleId)); try { const updatedRole = await updateRoleApi(request, roleId, updateData); return { success: true, roleData: updatedRole }; } catch (error: any) { const errorMessage = error.response?.data?.message || error.message || 'Failed to update role'; const statusCode = error.response?.status; setUpdateError(errorMessage); return { success: false, error: errorMessage, statusCode, isPermissionError: statusCode === 403, isValidationError: statusCode === 400 }; } finally { setEditingRoles(prev => { const newSet = new Set(prev); newSet.delete(roleId); return newSet; }); } }; // Generic inline update handler for FormGeneratorTable const handleInlineUpdate = async (roleId: string, changes: Partial) => { const result = await handleRoleUpdate(roleId, changes as RoleUpdateData); if (!result.success) { throw new Error(result.error || 'Failed to update'); } return result; }; return { deletingRoles, editingRoles, creatingRole, deleteError, createError, updateError, handleRoleDelete, handleRoleCreate, handleRoleUpdate, handleInlineUpdate, isLoading }; }