diff --git a/.env b/.env index 75d31ae..ec8b053 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -# VITE_API_BASE_URL=http://localhost:3000/api/v1 -VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 +VITE_API_BASE_URL=http://localhost:3000/api/v1 +# VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index 67a184a..3d1c929 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -1,22 +1,78 @@ -import { useEffect, useState, useRef } from 'react'; +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 } from 'lucide-react'; -import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; +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'; + +// 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.enum(['super_admin', 'tenant_admin', 'quality_manager', 'developer', 'viewer'], { - message: 'Role code 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'), - scope: z.enum(['platform', 'tenant', 'module'], { - message: 'Scope is required', - }), + module_ids: z.array(z.string().uuid()).optional().nullable(), + permissions: z.array(z.object({ + resource: z.string(), + action: z.string(), + })).optional().nullable(), }); type EditRoleFormData = z.infer; @@ -30,20 +86,6 @@ interface EditRoleModalProps { isLoading?: boolean; } -const scopeOptions = [ - { value: 'platform', label: 'Platform' }, - { value: 'tenant', label: 'Tenant' }, - { value: 'module', label: 'Module' }, -]; - -const roleCodeOptions = [ - { value: 'super_admin', label: 'Super Admin' }, - { value: 'tenant_admin', label: 'Tenant Admin' }, - { value: 'quality_manager', label: 'Quality Manager' }, - { value: 'developer', label: 'Developer' }, - { value: 'viewer', label: 'Viewer' }, -]; - export const EditRoleModal = ({ isOpen, onClose, @@ -55,6 +97,14 @@ export const EditRoleModal = ({ 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 tenant = useAppSelector((state) => state.auth.tenant); + const isSuperAdmin = roles.includes('super_admin'); + const [selectedModules, setSelectedModules] = useState([]); + const [selectedPermissions, setSelectedPermissions] = useState>([]); + const [initialModuleOptions, setInitialModuleOptions] = useState>([]); + const [expandedResources, setExpandedResources] = useState>(new Set()); const { register, @@ -67,10 +117,165 @@ export const EditRoleModal = ({ formState: { errors }, } = useForm({ resolver: zodResolver(editRoleSchema), + defaultValues: { + module_ids: [], + permissions: [], + }, }); - const scopeValue = watch('scope'); - const codeValue = watch('code'); + const nameValue = watch('name'); + + // Auto-generate code from name + useEffect(() => { + if (nameValue) { + const generatedCode = generateCodeFromName(nameValue); + setValue('code', generatedCode, { shouldValidate: true }); + } + }, [nameValue, setValue]); + + // Load modules from tenant assignedModules + const loadModules = async (page: number, limit: number) => { + const assignedModules = tenant?.assignedModules || []; + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedModules = assignedModules.slice(startIndex, endIndex); + + return { + options: paginatedModules.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: { + page, + limit, + total: assignedModules.length, + totalPages: Math.ceil(assignedModules.length / limit), + hasMore: endIndex < assignedModules.length, + }, + }; + }; + + // 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(() => { @@ -84,11 +289,51 @@ export const EditRoleModal = ({ clearErrors(); const role = await onLoadRole(roleId); loadedRoleIdRef.current = roleId; + + // Extract module_ids and permissions from role + const roleModuleIds = role.module_ids || []; + const rolePermissions = role.permissions || []; + + // Set modules if exists and user is not super_admin + if (roleModuleIds.length > 0 && !isSuperAdmin) { + setSelectedModules(roleModuleIds); + setValue('module_ids', roleModuleIds); + + // Load module names from tenant assignedModules + const assignedModules = tenant?.assignedModules || []; + const moduleOptions = roleModuleIds + .map((moduleId: string) => { + const module = assignedModules.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 }>; + setInitialModuleOptions(moduleOptions); + } else { + // Clear modules if super_admin or no modules + setSelectedModules([]); + setInitialModuleOptions([]); + } + + // 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 as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer', + code: role.code, description: role.description || '', - scope: role.scope, + // Only set module_ids if user is not super_admin + module_ids: isSuperAdmin ? [] : roleModuleIds, + permissions: rolePermissions, }); } catch (err: any) { setLoadError(err?.response?.data?.error?.message || 'Failed to load role details'); @@ -101,23 +346,33 @@ export const EditRoleModal = ({ } else if (!isOpen) { // Only reset when modal is closed loadedRoleIdRef.current = null; + setSelectedModules([]); + setSelectedPermissions([]); + setInitialModuleOptions([]); reset({ name: '', - code: undefined, + code: '', description: '', - scope: 'platform', + module_ids: [], + permissions: [], }); setLoadError(null); clearErrors(); } - }, [isOpen, roleId, onLoadRole, reset, clearErrors]); + }, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, tenant, isSuperAdmin]); const handleFormSubmit = async (data: EditRoleFormData): Promise => { if (!roleId) return; clearErrors(); try { - await onSubmit(roleId, data); + const submitData = { + ...data, + // Only include module_ids if user is not super_admin + module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : 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 @@ -125,7 +380,13 @@ export const EditRoleModal = ({ 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 === 'scope') { + if ( + detail.path === 'name' || + detail.path === 'code' || + detail.path === 'description' || + detail.path === 'module_ids' || + detail.path === 'permissions' + ) { setError(detail.path as keyof EditRoleFormData, { type: 'server', message: detail.message, @@ -134,7 +395,6 @@ export const EditRoleModal = ({ }); } else { // Handle general errors - // Check for nested error object with message property const errorObj = error?.response?.data?.error; const errorMessage = (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || @@ -158,7 +418,7 @@ export const EditRoleModal = ({ onClose={onClose} title="Edit Role" description="Update role by setting permissions and role type." - maxWidth="md" + maxWidth="lg" footer={ <> - +
{/* Role Name and Role Code Row */}
- setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')} + placeholder="Auto-generated from name" error={errors.code?.message} + {...register('code')} + disabled + className="bg-[#f3f4f6] cursor-not-allowed" />
@@ -233,16 +492,118 @@ export const EditRoleModal = ({ {...register('description')} /> - {/* Scope */} - setValue('scope', value as 'platform' | 'tenant' | 'module')} - error={errors.scope?.message} - /> + {/* Module Selection - Only show if user is not super_admin */} + {!isSuperAdmin && ( + { + setSelectedModules(values); + setValue('module_ids', values.length > 0 ? values : []); + }} + onLoadOptions={loadModules} + initialOptions={initialModuleOptions} + error={errors.module_ids?.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 ( + + ); + })} +
+
+ )} +
+ ); + })} +
+ )} +
+
)} diff --git a/src/components/shared/EditTenantModal.tsx b/src/components/shared/EditTenantModal.tsx index 2ddbde5..6d6e907 100644 --- a/src/components/shared/EditTenantModal.tsx +++ b/src/components/shared/EditTenantModal.tsx @@ -1,11 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } 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 } from 'lucide-react'; -import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; +import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import type { Tenant } from '@/types/tenant'; +import { moduleService } from '@/services/module-service'; // Validation schema - matches backend validation const editTenantSchema = z.object({ @@ -29,6 +30,7 @@ const editTenantSchema = z.object({ }).optional().nullable(), max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(), max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(), + modules: z.array(z.string().uuid()).optional().nullable(), }); type EditTenantFormData = z.infer; @@ -64,6 +66,9 @@ export const EditTenantModal = ({ }: EditTenantModalProps): ReactElement | null => { const [isLoadingTenant, setIsLoadingTenant] = useState(false); const [loadError, setLoadError] = useState(null); + const loadedTenantIdRef = useRef(null); + const [selectedModules, setSelectedModules] = useState([]); + const [initialModuleOptions, setInitialModuleOptions] = useState>([]); const { register, @@ -81,39 +86,76 @@ export const EditTenantModal = ({ const statusValue = watch('status'); const subscriptionTierValue = watch('subscription_tier'); - // Load tenant data when modal opens + // Load modules for multiselect + const loadModules = async (page: number, limit: number) => { + const response = await moduleService.getRunningModules(page, limit); + return { + options: response.data.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: response.pagination, + }; + }; + + // Load tenant data when modal opens - only load once per tenantId useEffect(() => { if (isOpen && tenantId) { - const loadTenant = async (): Promise => { - try { - setIsLoadingTenant(true); - setLoadError(null); - clearErrors(); - const tenant = await onLoadTenant(tenantId); - // Validate subscription_tier to match enum type - const validSubscriptionTier = tenant.subscription_tier === 'basic' || - tenant.subscription_tier === 'professional' || - tenant.subscription_tier === 'enterprise' - ? tenant.subscription_tier - : null; - - reset({ - name: tenant.name, - slug: tenant.slug, - status: tenant.status, - settings: tenant.settings, - subscription_tier: validSubscriptionTier, - max_users: tenant.max_users, - max_modules: tenant.max_modules, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); - } finally { - setIsLoadingTenant(false); - } - }; - loadTenant(); - } else { + // Only load if this is a new tenantId or modal was closed and reopened + if (loadedTenantIdRef.current !== tenantId) { + const loadTenant = async (): Promise => { + try { + setIsLoadingTenant(true); + setLoadError(null); + clearErrors(); + const tenant = await onLoadTenant(tenantId); + loadedTenantIdRef.current = tenantId; + + // Validate subscription_tier to match enum type + const validSubscriptionTier = tenant.subscription_tier === 'basic' || + tenant.subscription_tier === 'professional' || + tenant.subscription_tier === 'enterprise' + ? tenant.subscription_tier + : null; + + // Extract module IDs from assignedModules (preferred) or fallback to modules array + const tenantModules = tenant.assignedModules + ? tenant.assignedModules.map((module) => module.id) + : (tenant.modules || []); + + // Create initial options from assignedModules for display + const initialOptions = tenant.assignedModules + ? tenant.assignedModules.map((module) => ({ + value: module.id, + label: module.name, + })) + : []; + + setSelectedModules(tenantModules); + setInitialModuleOptions(initialOptions); + reset({ + name: tenant.name, + slug: tenant.slug, + status: tenant.status, + settings: tenant.settings, + subscription_tier: validSubscriptionTier, + max_users: tenant.max_users, + max_modules: tenant.max_modules, + modules: tenantModules, + }); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); + } finally { + setIsLoadingTenant(false); + } + }; + loadTenant(); + } + } else if (!isOpen) { + // Only reset when modal is closed + loadedTenantIdRef.current = null; + setSelectedModules([]); + setInitialModuleOptions([]); reset({ name: '', slug: '', @@ -122,6 +164,7 @@ export const EditTenantModal = ({ subscription_tier: null, max_users: null, max_modules: null, + modules: [], }); setLoadError(null); clearErrors(); @@ -133,7 +176,12 @@ export const EditTenantModal = ({ clearErrors(); try { - await onSubmit(tenantId, data); + const { modules, ...restData } = data; + const submitData = { + ...restData, + module_ids: selectedModules.length > 0 ? selectedModules : undefined, + }; + await onSubmit(tenantId, submitData); } catch (error: any) { // Handle validation errors from API if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { @@ -146,9 +194,12 @@ export const EditTenantModal = ({ detail.path === 'settings' || detail.path === 'subscription_tier' || detail.path === 'max_users' || - detail.path === 'max_modules' + detail.path === 'max_modules' || + detail.path === 'module_ids' ) { - setError(detail.path as keyof EditTenantFormData, { + // Map module_ids error to modules field for display + const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path; + setError(fieldPath as keyof EditTenantFormData, { type: 'server', message: detail.message, }); @@ -156,8 +207,11 @@ export const EditTenantModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (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 tenant. Please try again.'; @@ -300,6 +354,20 @@ export const EditTenantModal = ({ /> + + {/* Modules Multiselect */} + { + setSelectedModules(values); + setValue('modules', values.length > 0 ? values : []); + }} + onLoadOptions={loadModules} + initialOptions={initialModuleOptions} + error={errors.modules?.message} + /> )} diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 3598784..b844f63 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import type { ReactElement } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -55,6 +55,7 @@ export const EditUserModal = ({ }: EditUserModalProps): ReactElement | null => { const [isLoadingUser, setIsLoadingUser] = useState(false); const [loadError, setLoadError] = useState(null); + const loadedUserIdRef = useRef(null); const [selectedTenantId, setSelectedTenantId] = useState(''); const [selectedRoleId, setSelectedRoleId] = useState(''); @@ -194,51 +195,57 @@ export const EditUserModal = ({ }; }; - // Load user data when modal opens + // Load user data when modal opens - only load once per userId useEffect(() => { if (isOpen && userId) { - const loadUser = async (): Promise => { - try { - setIsLoadingUser(true); - setLoadError(null); - clearErrors(); - const user = await onLoadUser(userId); - - // Extract tenant and role IDs from nested objects or fallback to direct properties - const tenantId = user.tenant?.id || user.tenant_id || ''; - const roleId = user.role?.id || user.role_id || ''; - const tenantName = user.tenant?.name || ''; - const roleName = user.role?.name || ''; - - setSelectedTenantId(tenantId); - setSelectedRoleId(roleId); - setCurrentTenantName(tenantName); - setCurrentRoleName(roleName); - - // Set initial options for immediate display using names from user response - if (tenantId && tenantName) { - setInitialTenantOption({ value: tenantId, label: tenantName }); + // Only load if this is a new userId or modal was closed and reopened + if (loadedUserIdRef.current !== userId) { + const loadUser = async (): Promise => { + try { + setIsLoadingUser(true); + setLoadError(null); + clearErrors(); + const user = await onLoadUser(userId); + loadedUserIdRef.current = userId; + + // Extract tenant and role IDs from nested objects or fallback to direct properties + const tenantId = user.tenant?.id || user.tenant_id || ''; + const roleId = user.role?.id || user.role_id || ''; + const tenantName = user.tenant?.name || ''; + const roleName = user.role?.name || ''; + + setSelectedTenantId(tenantId); + setSelectedRoleId(roleId); + setCurrentTenantName(tenantName); + setCurrentRoleName(roleName); + + // Set initial options for immediate display using names from user response + if (tenantId && tenantName) { + setInitialTenantOption({ value: tenantId, label: tenantName }); + } + if (roleId && roleName) { + setInitialRoleOption({ value: roleId, label: roleName }); + } + + reset({ + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + status: user.status, + tenant_id: tenantId, + role_id: roleId, + }); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load user details'); + } finally { + setIsLoadingUser(false); } - if (roleId && roleName) { - setInitialRoleOption({ value: roleId, label: roleName }); - } - - reset({ - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - status: user.status, - tenant_id: tenantId, - role_id: roleId, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load user details'); - } finally { - setIsLoadingUser(false); - } - }; - loadUser(); - } else { + }; + loadUser(); + } + } else if (!isOpen) { + // Only reset when modal is closed + loadedUserIdRef.current = null; setSelectedTenantId(''); setSelectedRoleId(''); setCurrentTenantName(''); @@ -286,8 +293,11 @@ export const EditUserModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (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 user. Please try again.'; diff --git a/src/components/shared/MultiselectPaginatedSelect.tsx b/src/components/shared/MultiselectPaginatedSelect.tsx new file mode 100644 index 0000000..00184e0 --- /dev/null +++ b/src/components/shared/MultiselectPaginatedSelect.tsx @@ -0,0 +1,357 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import type { ReactElement } from 'react'; +import { ChevronDown, Loader2, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MultiselectPaginatedSelectOption { + value: string; + label: string; +} + +interface MultiselectPaginatedSelectProps { + label: string; + required?: boolean; + error?: string; + helperText?: string; + placeholder?: string; + value: string[]; + onValueChange: (value: string[]) => void; + onLoadOptions: (page: number, limit: number) => Promise<{ + options: MultiselectPaginatedSelectOption[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }; + }>; + initialOptions?: MultiselectPaginatedSelectOption[]; // Initial options to display before loading + className?: string; + id?: string; +} + +export const MultiselectPaginatedSelect = ({ + label, + required = false, + error, + helperText, + placeholder = 'Select Items', + value, + onValueChange, + onLoadOptions, + initialOptions = [], + className, + id, +}: MultiselectPaginatedSelectProps): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [pagination, setPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: 20, + total: 0, + totalPages: 1, + hasMore: false, + }); + + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const dropdownMenuRef = useRef(null); + const scrollContainerRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState<{ + top?: string; + bottom?: string; + left: string; + width: string; + }>({ left: '0', width: '0' }); + + // Load initial options + const loadOptions = useCallback( + async (page: number = 1, append: boolean = false) => { + try { + if (page === 1) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + + const result = await onLoadOptions(page, pagination.limit); + + if (append) { + setOptions((prev) => [...prev, ...result.options]); + } else { + setOptions(result.options); + } + + setPagination(result.pagination); + } catch (err) { + console.error('Error loading options:', err); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }, + [onLoadOptions, pagination.limit] + ); + + // Load options when dropdown opens + useEffect(() => { + if (isOpen) { + if (options.length === 0) { + loadOptions(1, false); + } + } + }, [isOpen, options.length, loadOptions]); + + // Handle scroll for infinite loading + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer || !isOpen) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isNearBottom && pagination.hasMore && !isLoadingMore && !isLoading) { + loadOptions(pagination.page + 1, true); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]); + + // Handle click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + dropdownRef.current && + !dropdownRef.current.contains(target) && + dropdownMenuRef.current && + !dropdownMenuRef.current.contains(target) + ) { + setIsOpen(false); + } + }; + + if (isOpen && buttonRef.current) { + document.addEventListener('mousedown', handleClickOutside); + + const rect = buttonRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const dropdownHeight = Math.min(240, 240); + + const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; + + if (shouldOpenUp) { + setDropdownStyle({ + bottom: `${window.innerHeight - rect.top + 5}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + }); + } else { + setDropdownStyle({ + top: `${rect.bottom + 5}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + }); + } + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const fieldId = id || `multiselect-${label.toLowerCase().replace(/\s+/g, '-')}`; + const hasError = Boolean(error); + // Combine loaded options with initial options, prioritizing loaded options + const allOptions = [...initialOptions, ...options.filter(opt => !initialOptions.some(init => init.value === opt.value))]; + const selectedOptions = allOptions.filter((opt) => value.includes(opt.value)); + + const handleToggle = (optionValue: string) => { + if (value.includes(optionValue)) { + onValueChange(value.filter((v) => v !== optionValue)); + } else { + onValueChange([...value, optionValue]); + } + }; + + const handleRemove = (optionValue: string, e: React.MouseEvent) => { + e.stopPropagation(); + onValueChange(value.filter((v) => v !== optionValue)); + }; + + const getDisplayText = (): string => { + if (value.length === 0) return placeholder; + if (value.length === 1) { + const option = options.find((opt) => opt.value === value[0]); + return option ? option.label : `${value.length} selected`; + } + return `${value.length} selected`; + }; + + return ( +
+ +
+ + + ))} + {value.length > 2 && ( + + +{value.length - 2} more + + )} + + )} +
+ + + + {isOpen && + buttonRef.current && + createPortal( +
+ {isLoading && allOptions.length === 0 ? ( +
+ +
+ ) : allOptions.length === 0 ? ( +
+

Not available

+
+ ) : ( + <> +
    + {allOptions.map((option) => { + const isSelected = value.includes(option.value); + return ( +
  • + +
  • + ); + })} + {isLoadingMore && ( +
  • + +
  • + )} +
+ + )} +
, + document.body + )} +
+ {error && ( + + )} + {helperText && !error && ( +

+ {helperText} +

+ )} + + ); +}; diff --git a/src/components/shared/NewModuleModal.tsx b/src/components/shared/NewModuleModal.tsx index cae522a..c4c85bb 100644 --- a/src/components/shared/NewModuleModal.tsx +++ b/src/components/shared/NewModuleModal.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; +import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared'; import { Copy, Check } from 'lucide-react'; import { showToast } from '@/utils/toast'; @@ -25,9 +25,6 @@ const newModuleSchema = z.object({ .min(1, 'version is required') .max(20, 'version must be at most 20 characters') .regex(/^[0-9]+\.[0-9]+\.[0-9]+$/, 'version format is invalid (must be X.Y.Z)'), - status: z.enum(['PENDING', 'ACTIVE', 'DEGRADED', 'SUSPENDED', 'DEPRECATED', 'RETIRED'], { - message: 'Invalid status', - }), runtime_language: z .string() .min(1, 'runtime_language is required') @@ -67,15 +64,6 @@ interface NewModuleModalProps { isLoading?: boolean; } -const statusOptions = [ - { value: 'PENDING', label: 'Pending' }, - { value: 'ACTIVE', label: 'Active' }, - { value: 'DEGRADED', label: 'Degraded' }, - { value: 'SUSPENDED', label: 'Suspended' }, - { value: 'DEPRECATED', label: 'Deprecated' }, - { value: 'RETIRED', label: 'Retired' }, -]; - export const NewModuleModal = ({ isOpen, onClose, @@ -88,8 +76,6 @@ export const NewModuleModal = ({ const { register, handleSubmit, - setValue, - watch, reset, setError, clearErrors, @@ -97,7 +83,6 @@ export const NewModuleModal = ({ } = useForm({ resolver: zodResolver(newModuleSchema), defaultValues: { - status: 'PENDING', description: null, framework: null, endpoints: null, @@ -117,8 +102,6 @@ export const NewModuleModal = ({ }, }); - const statusValue = watch('status'); - // Reset form when modal closes useEffect(() => { if (!isOpen) { @@ -127,7 +110,6 @@ export const NewModuleModal = ({ name: '', description: null, version: '', - status: 'PENDING', runtime_language: '', framework: null, base_url: '', @@ -173,7 +155,7 @@ export const NewModuleModal = ({ 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 === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'status' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'health_status' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') { + if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'health_status' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') { setError(detail.path as keyof NewModuleFormData, { type: 'server', message: detail.message, @@ -184,11 +166,11 @@ export const NewModuleModal = ({ // Handle general errors // Check for nested error object with message property const errorObj = error?.response?.data?.error; - const errorMessage = + const errorMessage = (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || (typeof errorObj === 'string' ? errorObj : null) || - error?.response?.data?.message || - error?.message || + error?.response?.data?.message || + error?.message || 'Failed to create module. Please try again.'; setError('root', { type: 'server', @@ -295,21 +277,27 @@ export const NewModuleModal = ({

Basic Information

- +
+
+ +
+
- + +
+
-
-
- -
-
+ {/*
+
*/} + + {/*
*/} + {/*
-
+
*/}
@@ -473,6 +461,6 @@ export const NewModuleModal = ({
- + ); }; diff --git a/src/components/shared/NewRoleModal.tsx b/src/components/shared/NewRoleModal.tsx index 021651c..6e9b99a 100644 --- a/src/components/shared/NewRoleModal.tsx +++ b/src/components/shared/NewRoleModal.tsx @@ -1,21 +1,78 @@ -import { useEffect } from 'react'; +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 { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; +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'; + +// 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 newRoleSchema = z.object({ name: z.string().min(1, 'Role name is required'), - code: z.enum(['super_admin', 'tenant_admin', 'quality_manager', 'developer', 'viewer'], { - message: 'Role code 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'), - scope: z.enum(['platform', 'tenant', 'module'], { - message: 'Scope is required', - }), + module_ids: z.array(z.string().uuid()).optional().nullable(), + permissions: z.array(z.object({ + resource: z.string(), + action: z.string(), + })).optional().nullable(), }); type NewRoleFormData = z.infer; @@ -27,26 +84,20 @@ interface NewRoleModalProps { isLoading?: boolean; } -const scopeOptions = [ - { value: 'platform', label: 'Platform' }, - { value: 'tenant', label: 'Tenant' }, - { value: 'module', label: 'Module' }, -]; - -const roleCodeOptions = [ - { value: 'super_admin', label: 'Super Admin' }, - { value: 'tenant_admin', label: 'Tenant Admin' }, - { value: 'quality_manager', label: 'Quality Manager' }, - { value: 'developer', label: 'Developer' }, - { value: 'viewer', label: 'Viewer' }, -]; - export const NewRoleModal = ({ isOpen, onClose, onSubmit, isLoading = false, }: NewRoleModalProps): ReactElement | null => { + const permissions = useAppSelector((state) => state.auth.permissions); + const roles = useAppSelector((state) => state.auth.roles); + const tenant = useAppSelector((state) => state.auth.tenant); + const isSuperAdmin = roles.includes('super_admin'); + const [selectedModules, setSelectedModules] = useState([]); + const [selectedPermissions, setSelectedPermissions] = useState>([]); + const [expandedResources, setExpandedResources] = useState>(new Set()); + const { register, handleSubmit, @@ -59,13 +110,21 @@ export const NewRoleModal = ({ } = useForm({ resolver: zodResolver(newRoleSchema), defaultValues: { - scope: 'platform', code: undefined, + module_ids: [], + permissions: [], }, }); - const scopeValue = watch('scope'); - const codeValue = watch('code'); + const nameValue = watch('name'); + + // Auto-generate code from name + useEffect(() => { + if (nameValue) { + const generatedCode = generateCodeFromName(nameValue); + setValue('code', generatedCode, { shouldValidate: true }); + } + }, [nameValue, setValue]); // Reset form when modal closes useEffect(() => { @@ -74,22 +133,150 @@ export const NewRoleModal = ({ name: '', code: undefined, description: '', - scope: 'platform', + module_ids: [], + permissions: [], }); + setSelectedModules([]); + setSelectedPermissions([]); clearErrors(); } }, [isOpen, reset, clearErrors]); + // Load modules from tenant assignedModules + const loadModules = async (page: number, limit: number) => { + const assignedModules = tenant?.assignedModules || []; + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedModules = assignedModules.slice(startIndex, endIndex); + + return { + options: paginatedModules.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: { + page, + limit, + total: assignedModules.length, + totalPages: Math.ceil(assignedModules.length / limit), + hasMore: endIndex < assignedModules.length, + }, + }; + }; + + // 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]); + const handleFormSubmit = async (data: NewRoleFormData): Promise => { clearErrors(); try { - await onSubmit(data); + const submitData = { + ...data, + // Only include module_ids if user is not super_admin + module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : 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)) { 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 === 'scope') { + if ( + detail.path === 'name' || + detail.path === 'code' || + detail.path === 'description' || + detail.path === 'module_ids' || + detail.path === 'permissions' + ) { setError(detail.path as keyof NewRoleFormData, { type: 'server', message: detail.message, @@ -98,13 +285,12 @@ export const NewRoleModal = ({ }); } else { // Handle general errors - // Check for nested error object with message property const errorObj = error?.response?.data?.error; - const errorMessage = + const errorMessage = (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || (typeof errorObj === 'string' ? errorObj : null) || - error?.response?.data?.message || - error?.message || + error?.response?.data?.message || + error?.message || 'Failed to create role. Please try again.'; setError('root', { type: 'server', @@ -120,7 +306,7 @@ export const NewRoleModal = ({ onClose={onClose} title="Create Role" description="Define a new role by setting permissions and role type." - maxWidth="md" + maxWidth="lg" footer={ <> } > -
+ {/* General Error Display */} {errors.root && (
@@ -161,14 +347,14 @@ export const NewRoleModal = ({ {...register('name')} /> - setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')} + placeholder="Auto-generated from name" error={errors.code?.message} + {...register('code')} + disabled + className="bg-[#f3f4f6] cursor-not-allowed" />
@@ -181,16 +367,109 @@ export const NewRoleModal = ({ {...register('description')} /> - {/* Scope */} - setValue('scope', value as 'platform' | 'tenant' | 'module')} - error={errors.scope?.message} - /> + {/* Module Selection - Only show if user is not super_admin */} + {!isSuperAdmin && ( + { + setSelectedModules(values); + setValue('module_ids', values.length > 0 ? values : []); + }} + onLoadOptions={loadModules} + error={errors.module_ids?.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 ( + + ); + })} +
+
+ )} +
+ ); + })} +
+ )} +
+
); diff --git a/src/components/shared/NewTenantModal.tsx b/src/components/shared/NewTenantModal.tsx index 0bcee4c..956f4aa 100644 --- a/src/components/shared/NewTenantModal.tsx +++ b/src/components/shared/NewTenantModal.tsx @@ -1,9 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; +import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; +import { moduleService } from '@/services/module-service'; // Validation schema - matches backend validation const newTenantSchema = z.object({ @@ -27,6 +28,7 @@ const newTenantSchema = z.object({ }).optional().nullable(), max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(), max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(), + modules: z.array(z.string().uuid()).optional().nullable(), }); type NewTenantFormData = z.infer; @@ -56,6 +58,8 @@ export const NewTenantModal = ({ onSubmit, isLoading = false, }: NewTenantModalProps): ReactElement | null => { + const [selectedModules, setSelectedModules] = useState([]); + const { register, handleSubmit, @@ -73,12 +77,25 @@ export const NewTenantModal = ({ subscription_tier: null, max_users: null, max_modules: null, + modules: [], }, }); const statusValue = watch('status'); const subscriptionTierValue = watch('subscription_tier'); + // Load modules for multiselect + const loadModules = async (page: number, limit: number) => { + const response = await moduleService.getRunningModules(page, limit); + return { + options: response.data.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: response.pagination, + }; + }; + // Reset form when modal closes useEffect(() => { if (!isOpen) { @@ -90,7 +107,9 @@ export const NewTenantModal = ({ subscription_tier: null, max_users: null, max_modules: null, + modules: [], }); + setSelectedModules([]); clearErrors(); } }, [isOpen, reset, clearErrors]); @@ -98,7 +117,12 @@ export const NewTenantModal = ({ const handleFormSubmit = async (data: NewTenantFormData): Promise => { clearErrors(); try { - await onSubmit(data); + const { modules, ...restData } = data; + const submitData = { + ...restData, + module_ids: selectedModules.length > 0 ? selectedModules : undefined, + }; + await onSubmit(submitData); } catch (error: any) { // Handle validation errors from API if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { @@ -111,9 +135,12 @@ export const NewTenantModal = ({ detail.path === 'settings' || detail.path === 'subscription_tier' || detail.path === 'max_users' || - detail.path === 'max_modules' + detail.path === 'max_modules' || + detail.path === 'module_ids' ) { - setError(detail.path as keyof NewTenantFormData, { + // Map module_ids error to modules field for display + const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path; + setError(fieldPath as keyof NewTenantFormData, { type: 'server', message: detail.message, }); @@ -121,8 +148,11 @@ export const NewTenantModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (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 tenant. Please try again.'; @@ -252,6 +282,19 @@ export const NewTenantModal = ({ /> + + {/* Modules Multiselect */} + { + setSelectedModules(values); + setValue('modules', values.length > 0 ? values : []); + }} + onLoadOptions={loadModules} + error={errors.modules?.message} + /> diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 82692a8..f0e63f9 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -150,8 +150,11 @@ export const NewUserModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (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 user. Please try again.'; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 9b7c21b..a6737b2 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -5,6 +5,7 @@ export { ActionDropdown } from './ActionDropdown'; export { FormField } from './FormField'; export { FormSelect } from './FormSelect'; export { PaginatedSelect } from './PaginatedSelect'; +export { MultiselectPaginatedSelect } from './MultiselectPaginatedSelect'; export { StatusBadge } from './StatusBadge'; export { Modal } from './Modal'; export { DataTable } from './DataTable'; diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 58918f7..8efac61 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -12,12 +12,30 @@ export interface User { last_name: string; } +export interface Permission { + resource: string; + action: string; +} + +export interface TenantModule { + id: string; + name: string; +} + +export interface Tenant { + id: string; + name: string; + assignedModules?: TenantModule[]; +} + export interface LoginResponse { success: boolean; data: { user: User; tenant_id: string; + tenant: Tenant; roles: string[]; + permissions: Permission[]; access_token: string; refresh_token: string; token_type: string; diff --git a/src/services/module-service.ts b/src/services/module-service.ts index 85a44cc..f8243f1 100644 --- a/src/services/module-service.ts +++ b/src/services/module-service.ts @@ -21,6 +21,30 @@ export const moduleService = { const response = await apiClient.get(`/modules?${params.toString()}`); return response.data; }, + getRunningModules: async ( + page: number = 1, + limit: number = 20 + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + params.append('status', 'running'); + const response = await apiClient.get(`/modules?${params.toString()}`); + return response.data; + }, + getModulesByTenant: async ( + tenantId: string, + page: number = 1, + limit: number = 20 + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + params.append('tenant_id', tenantId); + params.append('status', 'running'); + const response = await apiClient.get(`/modules?${params.toString()}`); + return response.data; + }, getById: async (id: string): Promise => { const response = await apiClient.get(`/modules/${id}`); return response.data; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index ae48ffe..bcdf3f3 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -1,5 +1,5 @@ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; -import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError } from '@/services/auth-service'; +import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError, type Permission, type Tenant } from '@/services/auth-service'; interface User { id: string; @@ -11,7 +11,9 @@ interface User { interface AuthState { user: User | null; tenantId: string | null; + tenant: Tenant | null; roles: string[]; + permissions: Permission[]; accessToken: string | null; refreshToken: string | null; tokenType: string | null; @@ -25,7 +27,9 @@ interface AuthState { const initialState: AuthState = { user: null, tenantId: null, + tenant: null, roles: [], + permissions: [], accessToken: null, refreshToken: null, tokenType: null, @@ -81,7 +85,9 @@ const authSlice = createSlice({ logout: (state) => { state.user = null; state.tenantId = null; + state.tenant = null; state.roles = []; + state.permissions = []; state.accessToken = null; state.refreshToken = null; state.tokenType = null; @@ -104,7 +110,9 @@ const authSlice = createSlice({ state.isLoading = false; state.user = action.payload.data.user; state.tenantId = action.payload.data.tenant_id; + state.tenant = action.payload.data.tenant; state.roles = action.payload.data.roles; + state.permissions = action.payload.data.permissions || []; state.accessToken = action.payload.data.access_token; state.refreshToken = action.payload.data.refresh_token; state.tokenType = action.payload.data.token_type; @@ -136,7 +144,9 @@ const authSlice = createSlice({ // Reset to initial state state.user = null; state.tenantId = null; + state.tenant = null; state.roles = []; + state.permissions = []; state.accessToken = null; state.refreshToken = null; state.tokenType = null; @@ -150,7 +160,9 @@ const authSlice = createSlice({ // Even if API call fails, clear local state state.user = null; state.tenantId = null; + state.tenant = null; state.roles = []; + state.permissions = []; state.accessToken = null; state.refreshToken = null; state.tokenType = null; diff --git a/src/store/store.ts b/src/store/store.ts index bbd6d75..5895402 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -7,7 +7,7 @@ import authReducer from './authSlice'; const authPersistConfig = { key: 'auth', storage, - whitelist: ['user', 'tenantId', 'roles', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'], + whitelist: ['user', 'tenantId', 'tenant', 'roles', 'permissions', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'], }; const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); diff --git a/src/types/role.ts b/src/types/role.ts index c394574..765fe17 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -5,6 +5,9 @@ export interface Role { description?: string; scope: 'platform' | 'tenant' | 'module'; is_system?: boolean; + tenant_id?: string | null; + module_ids?: string[] | null; + permissions?: Permission[] | null; created_at: string; updated_at: string; } @@ -23,11 +26,17 @@ export interface RolesResponse { pagination: Pagination; } +export interface Permission { + resource: string; + action: string; +} + export interface CreateRoleRequest { name: string; code: string; description: string; - scope: 'platform' | 'tenant' | 'module'; + module_ids?: string[] | null; + permissions?: Permission[] | null; } export interface CreateRoleResponse { @@ -45,7 +54,8 @@ export interface UpdateRoleRequest { name: string; code: string; description: string; - scope: 'platform' | 'tenant' | 'module'; + module_ids?: string[] | null; + permissions?: Permission[] | null; } export interface UpdateRoleResponse { diff --git a/src/types/tenant.ts b/src/types/tenant.ts index 4f1aedd..5ddb66c 100644 --- a/src/types/tenant.ts +++ b/src/types/tenant.ts @@ -2,6 +2,18 @@ export interface TenantSettings { timezone?: string; } +import type { Module } from './module'; + +export interface TenantModule { + assigned_at: string; + assigned_by: string | null; + status: string; +} + +export interface AssignedModule extends Module { + TenantModule: TenantModule; +} + export interface Tenant { id: string; name: string; @@ -11,6 +23,8 @@ export interface Tenant { subscription_tier: string | null; max_users: number | null; max_modules: number | null; + modules?: string[]; // Array of module IDs (legacy, for backward compatibility) + assignedModules?: AssignedModule[]; // Array of assigned modules with full details created_at: string; updated_at: string; }