diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index ff3ca5b..ab32a6e 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -1,79 +1,87 @@ -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'; +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 + .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', + "users", // 'tenants', - 'roles', - 'permissions', + "roles", + "permissions", // 'user_roles', // 'user_tenants', // 'sessions', // 'api_keys', // 'api_key_permissions', - 'projects', - 'document', - 'audit', - 'security', - 'workflow', - 'training', - 'capa', - 'supplier', - 'reports', - 'notifications', - 'files', - 'settings', + "projects", + "document", + "audit", + "security", + "workflow", + "training", + "capa", + "supplier", + "reports", + "notifications", + "files", + "settings", // 'modules', - 'audit_logs', + "audit_logs", // 'event_logs', // 'health_history', - 'qms_connections', - 'qms_sync_jobs', - 'qms_sync_conflicts', - 'qms_entity_mappings', - 'ai', - 'qms', + "qms_connections", + "qms_sync_jobs", + "qms_sync_conflicts", + "qms_entity_mappings", + "ai", + "qms", ]; // All available actions -const ALL_ACTIONS = ['create', 'read', 'update', 'delete']; +const ALL_ACTIONS = ["create", "read", "update", "delete"]; // Validation schema const editRoleSchema = z.object({ - name: z.string().min(1, 'Role name is required'), + name: z.string().min(1, "Role name is required"), code: z .string() - .min(1, 'Code is required') + .min(1, "Code is required") .regex( /^[a-z]+(_[a-z]+)*$/, - "Code must be lowercase and use '_' for separation (e.g. abc_def)" + "Code must be lowercase and use '_' for separation (e.g. abc_def)", ), - description: z.string().min(1, 'Description is required'), - modules: z.array(z.uuid()).optional().nullable(), - permissions: z.array(z.object({ - resource: z.string(), - action: z.string(), - })).optional().nullable(), + 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; @@ -103,11 +111,13 @@ export const EditRoleModal = ({ 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 isSuperAdmin = roles.includes("super_admin"); + const [selectedPermissions, setSelectedPermissions] = useState< + Array<{ resource: string; action: string }> + >([]); + const [expandedResources, setExpandedResources] = useState>( + new Set(), + ); const { register, @@ -121,36 +131,20 @@ export const EditRoleModal = ({ } = useForm({ resolver: zodResolver(editRoleSchema), defaultValues: { - modules: [], permissions: [], }, }); - const nameValue = watch('name'); + const nameValue = watch("name"); // Auto-generate code from name useEffect(() => { if (nameValue) { const generatedCode = generateCodeFromName(nameValue); - setValue('code', generatedCode, { shouldValidate: true }); + 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>(); @@ -158,14 +152,14 @@ export const EditRoleModal = ({ permissions.forEach((perm) => { const { resource, action } = perm; - if (resource === '*') { + 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 === "*") { // If action is also *, add all actions ALL_ACTIONS.forEach((act) => actions.add(act)); } else { @@ -178,7 +172,7 @@ export const EditRoleModal = ({ resourceMap.set(resource, new Set()); } const actions = resourceMap.get(resource)!; - if (action === '*') { + if (action === "*") { // If action is *, add all actions for this resource ALL_ACTIONS.forEach((act) => actions.add(act)); } else { @@ -191,17 +185,20 @@ export const EditRoleModal = ({ }, [permissions]); // Check if a resource has any selected actions - const hasSelectedActions = (resource: string, actions: Set): boolean => { + 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; + if (p.resource === "*" && p.action === action) return true; // Check for exact resource with wildcard action - if (p.resource === resource && p.action === '*') return true; + if (p.resource === resource && p.action === "*") return true; // Check for wildcard resource with wildcard action - if (p.resource === '*' && p.action === '*') return true; + if (p.resource === "*" && p.action === "*") return true; return false; }); }); @@ -221,17 +218,25 @@ export const EditRoleModal = ({ }; // Handle permission checkbox change - const handlePermissionChange = (resource: string, action: string, checked: boolean) => { + 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)) { + 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.filter( + (p) => !(p.resource === resource && p.action === action), + ); } return newPerms; }); @@ -239,20 +244,21 @@ export const EditRoleModal = ({ // Update form value when permissions change useEffect(() => { - setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []); + 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) { + if ( + selectedPermissions.length > 0 && + availableResourcesAndActions.size > 0 + ) { const resourcesWithPermissions = new Set(); selectedPermissions.forEach((perm) => { - if (perm.resource === '*') { + if (perm.resource === "*") { // If wildcard resource, expand all available resources availableResourcesAndActions.forEach((_, resource) => { resourcesWithPermissions.add(resource); @@ -277,7 +283,6 @@ export const EditRoleModal = ({ } }, [selectedPermissions, availableResourcesAndActions]); - // Load role data when modal opens - only load once per roleId useEffect(() => { if (isOpen && roleId) { @@ -291,71 +296,10 @@ export const EditRoleModal = ({ 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) + const rolePermissions = role.permissions || []; setSelectedPermissions(rolePermissions); - setValue('permissions', rolePermissions); + setValue("permissions", rolePermissions); // Expand resources that have selected permissions // This will be handled by useEffect after availableResourcesAndActions is computed @@ -363,12 +307,14 @@ export const EditRoleModal = ({ reset({ name: role.name, code: role.code, - description: role.description || '', - modules: roleModules, + description: role.description || "", permissions: rolePermissions, }); } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load role details'); + setLoadError( + err?.response?.data?.error?.message || + "Failed to load role details", + ); } finally { setIsLoadingRole(false); } @@ -378,67 +324,86 @@ export const EditRoleModal = ({ } else if (!isOpen) { // Only reset when modal is closed loadedRoleIdRef.current = null; - setSelectedAvailableModules([]); setSelectedPermissions([]); - setInitialAvailableModuleOptions([]); reset({ - name: '', - code: '', - description: '', - modules: [], + name: "", + code: "", + description: "", permissions: [], }); setLoadError(null); clearErrors(); } - }, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, isSuperAdmin, defaultTenantId, tenantIdFromAuth]); + }, [ + 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, + 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)) { + 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, - }); - } - }); + 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.', + 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 @@ -470,7 +435,7 @@ export const EditRoleModal = ({ size="default" className="px-4 py-2.5 text-sm" > - {isLoading ? 'Updating...' : 'Update Role'} + {isLoading ? "Updating..." : "Update Role"} } @@ -495,7 +460,10 @@ export const EditRoleModal = ({ )} {!isLoadingRole && ( -
+ {/* Role Name and Role Code Row */}
@@ -523,115 +491,134 @@ export const EditRoleModal = ({ required placeholder="Enter Text Here" error={errors.description?.message} - {...register('description')} + {...register("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}

+

+ {errors.permissions.message} +

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

No permissions available

+ {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 ( - - ); - })} + {hasSelected && ( + + Selected + + )}
-
- )} -
- ); - })} + + {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 ( + + ); + })} +
+
+ )} +
+ ); + }, + )}
)}
diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 4f4e4a0..0488c2c 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -16,6 +16,8 @@ import { import { roleService } from "@/services/role-service"; import { departmentService } from "@/services/department-service"; import { designationService } from "@/services/designation-service"; +import { moduleService } from "@/services/module-service"; +import { useAppSelector } from "@/hooks/redux-hooks"; import type { User } from "@/types/user"; // Validation schema @@ -30,6 +32,7 @@ const editUserSchema = z.object({ role_ids: z.array(z.string()).min(1, "At least one role is required"), department_id: z.string().optional(), designation_id: z.string().optional(), + module_ids: z.array(z.string()).optional(), }); type EditUserFormData = z.infer; @@ -80,6 +83,11 @@ export const EditUserModal = ({ const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); + const moduleIdsValue = watch("module_ids"); + + const rolesFromAuth = useAppSelector((state) => state.auth.roles); + const isSuperAdmin = rolesFromAuth.includes("super_admin"); + const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId); const [initialRoleOptions, setInitialRoleOptions] = useState< { value: string; label: string }[] @@ -92,6 +100,9 @@ export const EditUserModal = ({ value: string; label: string; } | null>(null); + const [initialModuleOptions, setInitialModuleOptions] = useState< + { value: string; label: string }[] + >([]); // Load roles for dropdown - ensure selected role is included const loadRoles = async (page: number, limit: number) => { @@ -145,6 +156,18 @@ export const EditUserModal = ({ }; }; + const loadModules = 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, + }; + }; + // Load user data when modal opens useEffect(() => { if (isOpen && userId) { @@ -200,8 +223,15 @@ export const EditUserModal = ({ role_ids: roleIds, department_id: departmentId, designation_id: designationId, + module_ids: user.modules?.map((m) => m.id) || [], }); + if (user.modules) { + setInitialModuleOptions( + user.modules.map((m) => ({ value: m.id, label: m.name })), + ); + } + if (defaultTenantId) { setValue("tenant_id", defaultTenantId, { shouldValidate: true }); } @@ -230,7 +260,9 @@ export const EditUserModal = ({ role_ids: [], department_id: "", designation_id: "", + module_ids: [], }); + setInitialModuleOptions([]); setLoadError(null); clearErrors(); } @@ -412,6 +444,18 @@ export const EditUserModal = ({ error={errors.status?.message} />
+ +
+ setValue("module_ids", value)} + onLoadOptions={loadModules} + initialOptions={initialModuleOptions} + error={errors.module_ids?.message} + /> +
)}
diff --git a/src/components/shared/NewRoleModal.tsx b/src/components/shared/NewRoleModal.tsx index eece6e6..2761e34 100644 --- a/src/components/shared/NewRoleModal.tsx +++ b/src/components/shared/NewRoleModal.tsx @@ -1,79 +1,87 @@ -import { useEffect, useState, 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 { ChevronDown, ChevronRight } from 'lucide-react'; -import { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; -import type { CreateRoleRequest } from '@/types/role'; -import { useAppSelector } from '@/hooks/redux-hooks'; -import { moduleService } from '@/services/module-service'; +import { useEffect, useState, 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 { ChevronDown, ChevronRight } from "lucide-react"; +import { + Modal, + FormField, + PrimaryButton, + SecondaryButton, +} from "@/components/shared"; +import type { CreateRoleRequest } 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 + .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', + "users", // 'tenants', - 'roles', - 'permissions', + "roles", + "permissions", // 'user_roles', // 'user_tenants', // 'sessions', // 'api_keys', // 'api_key_permissions', - 'projects', - 'document', - 'audit', - 'security', - 'workflow', - 'training', - 'capa', - 'supplier', - 'reports', - 'notifications', - 'files', - 'settings', + "projects", + "document", + "audit", + "security", + "workflow", + "training", + "capa", + "supplier", + "reports", + "notifications", + "files", + "settings", // 'modules', - 'audit_logs', + "audit_logs", // 'event_logs', // 'health_history', - 'qms_connections', - 'qms_sync_jobs', - 'qms_sync_conflicts', - 'qms_entity_mappings', - 'ai', - 'qms', + "qms_connections", + "qms_sync_jobs", + "qms_sync_conflicts", + "qms_entity_mappings", + "ai", + "qms", ]; // All available actions -const ALL_ACTIONS = ['create', 'read', 'update', 'delete']; +const ALL_ACTIONS = ["create", "read", "update", "delete"]; // Validation schema const newRoleSchema = z.object({ - name: z.string().min(1, 'Role name is required'), + name: z.string().min(1, "Role name is required"), code: z .string() - .min(1, 'Code is required') + .min(1, "Code is required") .regex( /^[a-z]+(_[a-z]+)*$/, - "Code must be lowercase and use '_' for separation (e.g. abc_def)" + "Code must be lowercase and use '_' for separation (e.g. abc_def)", ), - description: z.string().min(1, 'Description is required'), - modules: z.array(z.uuid()).optional().nullable(), - permissions: z.array(z.object({ - resource: z.string(), - action: z.string(), - })).optional().nullable(), + description: z.string().min(1, "Description is required"), + permissions: z + .array( + z.object({ + resource: z.string(), + action: z.string(), + }), + ) + .optional() + .nullable(), }); type NewRoleFormData = z.infer; @@ -95,10 +103,13 @@ export const NewRoleModal = ({ }: NewRoleModalProps): ReactElement | null => { const permissions = useAppSelector((state) => state.auth.permissions); const roles = useAppSelector((state) => state.auth.roles); - const isSuperAdmin = roles.includes('super_admin'); - const [selectedAvailableModules, setSelectedAvailableModules] = useState([]); - const [selectedPermissions, setSelectedPermissions] = useState>([]); - const [expandedResources, setExpandedResources] = useState>(new Set()); + const isSuperAdmin = roles.includes("super_admin"); + const [selectedPermissions, setSelectedPermissions] = useState< + Array<{ resource: string; action: string }> + >([]); + const [expandedResources, setExpandedResources] = useState>( + new Set(), + ); const { register, @@ -113,18 +124,17 @@ export const NewRoleModal = ({ resolver: zodResolver(newRoleSchema), defaultValues: { code: undefined, - modules: [], permissions: [], }, }); - const nameValue = watch('name'); + const nameValue = watch("name"); // Auto-generate code from name useEffect(() => { if (nameValue) { const generatedCode = generateCodeFromName(nameValue); - setValue('code', generatedCode, { shouldValidate: true }); + setValue("code", generatedCode, { shouldValidate: true }); } }, [nameValue, setValue]); @@ -132,32 +142,16 @@ export const NewRoleModal = ({ useEffect(() => { if (!isOpen) { reset({ - name: '', + name: "", code: undefined, - description: '', - modules: [], + description: "", permissions: [], }); - setSelectedAvailableModules([]); setSelectedPermissions([]); clearErrors(); } }, [isOpen, reset, clearErrors]); - // Load available modules from /modules/available endpoint - // For super_admin, send tenant_id if defaultTenantId is provided - const loadAvailableModules = async (page: number, limit: number) => { - const tenantId = isSuperAdmin ? defaultTenantId : undefined; - 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>(); @@ -165,14 +159,14 @@ export const NewRoleModal = ({ permissions.forEach((perm) => { const { resource, action } = perm; - if (resource === '*') { + 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 === "*") { // If action is also *, add all actions ALL_ACTIONS.forEach((act) => actions.add(act)); } else { @@ -185,7 +179,7 @@ export const NewRoleModal = ({ resourceMap.set(resource, new Set()); } const actions = resourceMap.get(resource)!; - if (action === '*') { + if (action === "*") { // If action is *, add all actions for this resource ALL_ACTIONS.forEach((act) => actions.add(act)); } else { @@ -198,17 +192,20 @@ export const NewRoleModal = ({ }, [permissions]); // Check if a resource has any selected actions - const hasSelectedActions = (resource: string, actions: Set): boolean => { + 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; + if (p.resource === "*" && p.action === action) return true; // Check for exact resource with wildcard action - if (p.resource === resource && p.action === '*') return true; + if (p.resource === resource && p.action === "*") return true; // Check for wildcard resource with wildcard action - if (p.resource === '*' && p.action === '*') return true; + if (p.resource === "*" && p.action === "*") return true; return false; }); }); @@ -228,17 +225,25 @@ export const NewRoleModal = ({ }; // Handle permission checkbox change - const handlePermissionChange = (resource: string, action: string, checked: boolean) => { + 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)) { + 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.filter( + (p) => !(p.resource === resource && p.action === action), + ); } return newPerms; }); @@ -246,56 +251,66 @@ export const NewRoleModal = ({ // Update form value when permissions change useEffect(() => { - setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []); + setValue( + "permissions", + selectedPermissions.length > 0 ? selectedPermissions : [], + ); }, [selectedPermissions, setValue]); - // Update form value when available modules change - useEffect(() => { - setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []); - }, [selectedAvailableModules, setValue]); - const handleFormSubmit = async (data: NewRoleFormData): Promise => { 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, + tenant_id: isSuperAdmin + ? defaultTenantId || undefined + : defaultTenantId || undefined, + permissions: + selectedPermissions.length > 0 ? selectedPermissions : undefined, }; await onSubmit(submitData as CreateRoleRequest); } catch (error: any) { // Handle validation errors from API - if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { + 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 NewRoleFormData, { - type: 'server', - message: detail.message, - }); - } - }); + 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 NewRoleFormData, { + 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 create role. Please try again.'; - setError('root', { - type: 'server', - message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create role. Please try again.', + 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 create role. Please try again."; + setError("root", { + type: "server", + message: + typeof errorMessage === "string" + ? errorMessage + : "Failed to create role. Please try again.", }); } } @@ -325,12 +340,15 @@ export const NewRoleModal = ({ size="default" className="px-4 py-2.5 text-sm" > - {isLoading ? 'Creating...' : 'Create Role'} + {isLoading ? "Creating..." : "Create Role"} } > -
+ {/* General Error Display */} {errors.root && (
@@ -345,7 +363,7 @@ export const NewRoleModal = ({ required placeholder="Enter Text Here" error={errors.name?.message} - {...register('name')} + {...register("name")} /> @@ -365,106 +383,122 @@ export const NewRoleModal = ({ required placeholder="Enter Text Here" error={errors.description?.message} - {...register('description')} + {...register("description")} /> - {/* Available Modules Selection */} - { - setSelectedAvailableModules(values); - setValue('modules', values.length > 0 ? values : []); - }} - onLoadOptions={loadAvailableModules} - error={errors.modules?.message} - /> - {/* Permissions Section */}
{errors.permissions && ( -

{errors.permissions.message}

+

+ {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 ( - - ); - })} + {hasSelected && ( + + Selected + + )}
-
- )} -
- ); - })} + + {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 ( + + ); + })} +
+
+ )} +
+ ); + }, + )}
)}
diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 89becfa..96d2b69 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -15,6 +15,8 @@ import { import { roleService } from "@/services/role-service"; import { departmentService } from "@/services/department-service"; import { designationService } from "@/services/designation-service"; +import { moduleService } from "@/services/module-service"; +import { useAppSelector } from "@/hooks/redux-hooks"; // Validation schema const newUserSchema = z @@ -36,6 +38,7 @@ const newUserSchema = z role_ids: z.array(z.string()).min(1, "At least one role is required"), department_id: z.string().optional(), designation_id: z.string().optional(), + module_ids: z.array(z.string()).optional(), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", @@ -80,6 +83,7 @@ export const NewUserModal = ({ role_ids: [], department_id: "", designation_id: "", + module_ids: [], }, }); @@ -87,6 +91,10 @@ export const NewUserModal = ({ const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); + const moduleIdsValue = watch("module_ids"); + + const roles = useAppSelector((state) => state.auth.roles); + const isSuperAdmin = roles.includes("super_admin"); // Reset form when modal closes useEffect(() => { @@ -102,6 +110,7 @@ export const NewUserModal = ({ role_ids: [], department_id: "", designation_id: "", + module_ids: [], }); clearErrors(); } @@ -159,6 +168,18 @@ export const NewUserModal = ({ }; }; + const loadModules = async (page: number, limit: number) => { + const tenantId = isSuperAdmin ? defaultTenantId : undefined; + const response = await moduleService.getAvailable(page, limit, tenantId); + return { + options: response.data.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: response.pagination, + }; + }; + const handleFormSubmit = async (data: NewUserFormData): Promise => { clearErrors(); try { @@ -180,7 +201,8 @@ export const NewUserModal = ({ detail.path === "last_name" || detail.path === "status" || detail.path === "auth_provider" || - detail.path === "role_ids" + detail.path === "role_ids" || + detail.path === "module_ids" ) { setError(detail.path as keyof NewUserFormData, { type: "server", @@ -346,6 +368,17 @@ export const NewUserModal = ({ error={errors.status?.message} />
+ +
+ setValue("module_ids", value)} + onLoadOptions={loadModules} + error={errors.module_ids?.message} + /> +
diff --git a/src/components/shared/WorkflowDefinitionModal.tsx b/src/components/shared/WorkflowDefinitionModal.tsx index 804abe9..a4020f5 100644 --- a/src/components/shared/WorkflowDefinitionModal.tsx +++ b/src/components/shared/WorkflowDefinitionModal.tsx @@ -387,7 +387,7 @@ export const WorkflowDefinitionModal = ({ const payload: any = { ...data, source_module: selectedModuleNames, - tenant_id: tenantId || undefined, + tenantId: tenantId || undefined, }; // In edit mode, if steps or transitions are not edited (hidden in UI) diff --git a/src/components/shared/WorkflowDefinitionsTable.tsx b/src/components/shared/WorkflowDefinitionsTable.tsx index 5941160..7d6b7a8 100644 --- a/src/components/shared/WorkflowDefinitionsTable.tsx +++ b/src/components/shared/WorkflowDefinitionsTable.tsx @@ -24,12 +24,12 @@ interface WorkflowDefinitionsTableProps { } const WorkflowDefinitionsTable = ({ - tenantId: propsTenantId, + tenantId: tenantId, compact = false, showHeader = true, }: WorkflowDefinitionsTableProps): ReactElement => { const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); - const effectiveTenantId = propsTenantId || reduxTenantId || undefined; + const effectiveTenantId = tenantId || reduxTenantId || undefined; const [definitions, setDefinitions] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -232,11 +232,11 @@ const WorkflowDefinitionsTable = ({ ), }, { - key: "updated_at", - label: "Last Updated", + key: "created_at", + label: "Created Date", render: (wf) => ( - {formatDate(wf.updated_at)} + {formatDate(wf.created_at)} ), }, @@ -288,6 +288,16 @@ const WorkflowDefinitionsTable = ({ )} + {wf.status === "deprecated" && ( + + )}