Enhance EditRoleModal with improved role management features, including dynamic module and permission selection based on user roles. Implement auto-generation of role code from name input, and update validation schema to support new permissions structure. Refactor form handling for better user experience and error management.
This commit is contained in:
parent
2c8a3459e4
commit
9a0d28145a
4
.env
4
.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
|
||||
|
||||
@ -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<typeof editRoleSchema>;
|
||||
@ -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<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const loadedRoleIdRef = useRef<string | null>(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<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
|
||||
const [initialModuleOptions, setInitialModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -67,10 +117,165 @@ export const EditRoleModal = ({
|
||||
formState: { errors },
|
||||
} = useForm<EditRoleFormData>({
|
||||
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<string, Set<string>>();
|
||||
|
||||
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<string>): 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<string>();
|
||||
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<void> => {
|
||||
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={
|
||||
<>
|
||||
<SecondaryButton
|
||||
@ -201,8 +461,7 @@ export const EditRoleModal = ({
|
||||
)}
|
||||
|
||||
{!isLoadingRole && (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
|
||||
{/* Role Name and Role Code Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
@ -213,14 +472,14 @@ export const EditRoleModal = ({
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
<FormField
|
||||
label="Role Code"
|
||||
required
|
||||
placeholder="Select Role Code"
|
||||
options={roleCodeOptions}
|
||||
value={codeValue}
|
||||
onValueChange={(value) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -233,16 +492,118 @@ export const EditRoleModal = ({
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Scope */}
|
||||
<FormSelect
|
||||
label="Scope"
|
||||
required
|
||||
placeholder="Select Scope"
|
||||
options={scopeOptions}
|
||||
value={scopeValue}
|
||||
onValueChange={(value) => setValue('scope', value as 'platform' | 'tenant' | 'module')}
|
||||
error={errors.scope?.message}
|
||||
/>
|
||||
{/* Module Selection - Only show if user is not super_admin */}
|
||||
{!isSuperAdmin && (
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('module_ids', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
initialOptions={initialModuleOptions}
|
||||
error={errors.module_ids?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="pb-4">
|
||||
<label className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a] mb-3">
|
||||
<span>Permissions</span>
|
||||
</label>
|
||||
{errors.permissions && (
|
||||
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
|
||||
)}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
|
||||
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
|
||||
<p className="text-sm text-[#6b7280]">No permissions available</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Array.from(availableResourcesAndActions.entries()).map(([resource, actions]) => {
|
||||
const isExpanded = expandedResources.has(resource);
|
||||
const hasSelected = hasSelectedActions(resource, actions);
|
||||
return (
|
||||
<div
|
||||
key={resource}
|
||||
className={`border border-[rgba(0,0,0,0.08)] rounded-md overflow-hidden transition-colors ${
|
||||
hasSelected ? 'bg-[rgba(17,40,104,0.05)]' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-[rgba(0,0,0,0.02)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-[#0e1b2a]" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-[#0e1b2a]" />
|
||||
)}
|
||||
<span
|
||||
className={`font-medium text-sm capitalize ${
|
||||
hasSelected ? 'text-[#112868]' : 'text-[#0e1b2a]'
|
||||
}`}
|
||||
>
|
||||
{resource.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{hasSelected && (
|
||||
<span className="text-xs text-[#112868] bg-[rgba(17,40,104,0.1)] px-2 py-0.5 rounded">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-2 border-t border-[rgba(0,0,0,0.05)]">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{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 (
|
||||
<label
|
||||
key={`${resource}-${action}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handlePermissionChange(resource, action, e.target.checked)}
|
||||
className="w-4 h-4 text-[#112868] border-[rgba(0,0,0,0.2)] rounded focus:ring-[#112868]/20"
|
||||
/>
|
||||
<span className="text-sm text-[#0e1b2a] capitalize">{action}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@ -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<typeof editTenantSchema>;
|
||||
@ -64,6 +66,9 @@ export const EditTenantModal = ({
|
||||
}: EditTenantModalProps): ReactElement | null => {
|
||||
const [isLoadingTenant, setIsLoadingTenant] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const loadedTenantIdRef = useRef<string | null>(null);
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
const [initialModuleOptions, setInitialModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Multiselect */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
initialOptions={initialModuleOptions}
|
||||
error={errors.modules?.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@ -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<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const loadedUserIdRef = useRef<string | null>(null);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>('');
|
||||
|
||||
@ -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<void> => {
|
||||
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<void> => {
|
||||
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.';
|
||||
|
||||
357
src/components/shared/MultiselectPaginatedSelect.tsx
Normal file
357
src/components/shared/MultiselectPaginatedSelect.tsx
Normal file
@ -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<boolean>(false);
|
||||
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLUListElement>(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 (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
id={fieldId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors',
|
||||
'flex items-center justify-between gap-2',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<div className="flex-1 flex flex-wrap gap-1.5 items-center min-h-[24px]">
|
||||
{value.length === 0 ? (
|
||||
<span className="text-[#9aa6b2]">{placeholder}</span>
|
||||
) : (
|
||||
<>
|
||||
{selectedOptions.slice(0, 2).map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-[#112868]/10 text-[#112868] rounded text-xs font-medium"
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemove(option.value, e)}
|
||||
className="hover:bg-[#112868]/20 rounded p-0.5 transition-colors"
|
||||
aria-label={`Remove ${option.label}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{value.length > 2 && (
|
||||
<span className="text-[#0e1b2a] text-xs">
|
||||
+{value.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn('w-4 h-4 transition-transform flex-shrink-0', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
buttonRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-hidden flex flex-col"
|
||||
style={dropdownStyle}
|
||||
data-dropdown-menu="true"
|
||||
>
|
||||
{isLoading && allOptions.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
) : allOptions.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-sm text-[#6b7280]">Not available</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul
|
||||
ref={scrollContainerRef}
|
||||
role="listbox"
|
||||
className="py-1.5 overflow-y-auto flex-1"
|
||||
>
|
||||
{allOptions.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<li key={option.value} role="option" aria-selected={isSelected}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle(option.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
'flex items-center gap-2',
|
||||
isSelected && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 h-4 border rounded flex items-center justify-center flex-shrink-0',
|
||||
isSelected
|
||||
? 'bg-[#112868] border-[#112868]'
|
||||
: 'border-[rgba(0,0,0,0.2)]'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{isLoadingMore && (
|
||||
<li className="flex items-center justify-center py-2">
|
||||
<Loader2 className="w-4 h-4 text-[#112868] animate-spin" />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<NewModuleFormData>({
|
||||
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 = ({
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Basic Information</h3>
|
||||
<div className="flex flex-col gap-0">
|
||||
<FormField
|
||||
label="Module ID"
|
||||
required
|
||||
placeholder="Enter module ID (e.g., my-module)"
|
||||
error={errors.module_id?.message}
|
||||
{...register('module_id')}
|
||||
/>
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Module ID"
|
||||
required
|
||||
placeholder="Enter module ID (e.g., my-module)"
|
||||
error={errors.module_id?.message}
|
||||
{...register('module_id')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
<FormField
|
||||
label="Module Name"
|
||||
required
|
||||
placeholder="Enter module name"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
<FormField
|
||||
label="Module Name"
|
||||
required
|
||||
placeholder="Enter module name"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
@ -318,17 +306,17 @@ export const NewModuleModal = ({
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Version"
|
||||
required
|
||||
placeholder="e.g., 1.0.0"
|
||||
error={errors.version?.message}
|
||||
{...register('version')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{/* <div className="flex gap-5">
|
||||
<div className="flex-1"> */}
|
||||
<FormField
|
||||
label="Version"
|
||||
required
|
||||
placeholder="e.g., 1.0.0"
|
||||
error={errors.version?.message}
|
||||
{...register('version')}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* <div className="flex-1">
|
||||
<FormSelect
|
||||
label="Status"
|
||||
placeholder="Select Status"
|
||||
@ -338,7 +326,7 @@ export const NewModuleModal = ({
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -473,6 +461,6 @@ export const NewModuleModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Modal >
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<typeof newRoleSchema>;
|
||||
@ -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<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -59,13 +110,21 @@ export const NewRoleModal = ({
|
||||
} = useForm<NewRoleFormData>({
|
||||
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<string, Set<string>>();
|
||||
|
||||
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<string>): 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<void> => {
|
||||
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={
|
||||
<>
|
||||
<SecondaryButton
|
||||
@ -143,7 +329,7 @@ export const NewRoleModal = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
@ -161,14 +347,14 @@ export const NewRoleModal = ({
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
<FormField
|
||||
label="Role Code"
|
||||
required
|
||||
placeholder="Select Role Code"
|
||||
options={roleCodeOptions}
|
||||
value={codeValue}
|
||||
onValueChange={(value) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -181,16 +367,109 @@ export const NewRoleModal = ({
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Scope */}
|
||||
<FormSelect
|
||||
label="Scope"
|
||||
required
|
||||
placeholder="Select Scope"
|
||||
options={scopeOptions}
|
||||
value={scopeValue}
|
||||
onValueChange={(value) => setValue('scope', value as 'platform' | 'tenant' | 'module')}
|
||||
error={errors.scope?.message}
|
||||
/>
|
||||
{/* Module Selection - Only show if user is not super_admin */}
|
||||
{!isSuperAdmin && (
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('module_ids', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
error={errors.module_ids?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="pb-4">
|
||||
<label className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a] mb-3">
|
||||
<span>Permissions</span>
|
||||
</label>
|
||||
{errors.permissions && (
|
||||
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
|
||||
)}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
|
||||
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
|
||||
<p className="text-sm text-[#6b7280]">No permissions available</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Array.from(availableResourcesAndActions.entries()).map(([resource, actions]) => {
|
||||
const isExpanded = expandedResources.has(resource);
|
||||
const hasSelected = hasSelectedActions(resource, actions);
|
||||
return (
|
||||
<div
|
||||
key={resource}
|
||||
className={`border border-[rgba(0,0,0,0.08)] rounded-md overflow-hidden transition-colors ${
|
||||
hasSelected ? 'bg-[rgba(17,40,104,0.05)]' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-[rgba(0,0,0,0.02)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-[#0e1b2a]" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-[#0e1b2a]" />
|
||||
)}
|
||||
<span
|
||||
className={`font-medium text-sm capitalize ${
|
||||
hasSelected ? 'text-[#112868]' : 'text-[#0e1b2a]'
|
||||
}`}
|
||||
>
|
||||
{resource.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{hasSelected && (
|
||||
<span className="text-xs text-[#112868] bg-[rgba(17,40,104,0.1)] px-2 py-0.5 rounded">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-2 border-t border-[rgba(0,0,0,0.05)]">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{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 (
|
||||
<label
|
||||
key={`${resource}-${action}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handlePermissionChange(resource, action, e.target.checked)}
|
||||
className="w-4 h-4 text-[#112868] border-[rgba(0,0,0,0.2)] rounded focus:ring-[#112868]/20"
|
||||
/>
|
||||
<span className="text-sm text-[#0e1b2a] capitalize">{action}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -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<typeof newTenantSchema>;
|
||||
@ -56,6 +58,8 @@ export const NewTenantModal = ({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewTenantModalProps): ReactElement | null => {
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
|
||||
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<void> => {
|
||||
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 = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Multiselect */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
error={errors.modules?.message}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -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.';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -21,6 +21,30 @@ export const moduleService = {
|
||||
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getRunningModules: async (
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<ModulesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
params.append('status', 'running');
|
||||
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getModulesByTenant: async (
|
||||
tenantId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<ModulesResponse> => {
|
||||
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<ModulesResponse>(`/modules?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetModuleResponse> => {
|
||||
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
|
||||
return response.data;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user