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:
Yashwin 2026-01-23 18:04:28 +05:30
parent 2c8a3459e4
commit 9a0d28145a
16 changed files with 1432 additions and 244 deletions

4
.env
View File

@ -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

View File

@ -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>

View File

@ -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,15 +86,31 @@ 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) {
// 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' ||
@ -97,6 +118,21 @@ export const EditTenantModal = ({
? 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,
@ -105,6 +141,7 @@ export const EditTenantModal = ({
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');
@ -113,7 +150,12 @@ export const EditTenantModal = ({
}
};
loadTenant();
} else {
}
} 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>

View File

@ -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,15 +195,18 @@ export const EditUserModal = ({
};
};
// Load user data when modal opens
// Load user data when modal opens - only load once per userId
useEffect(() => {
if (isOpen && userId) {
// 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 || '';
@ -238,7 +242,10 @@ export const EditUserModal = ({
}
};
loadUser();
} else {
}
} 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.';

View 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>
);
};

View File

@ -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,
@ -295,6 +277,8 @@ 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">
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Module ID"
required
@ -302,6 +286,8 @@ export const NewModuleModal = ({
error={errors.module_id?.message}
{...register('module_id')}
/>
</div>
<div className="flex-1">
<FormField
label="Module Name"
@ -310,6 +296,8 @@ export const NewModuleModal = ({
error={errors.name?.message}
{...register('name')}
/>
</div>
</div>
<FormField
label="Description"
@ -318,8 +306,8 @@ export const NewModuleModal = ({
{...register('description')}
/>
<div className="flex gap-5">
<div className="flex-1">
{/* <div className="flex gap-5">
<div className="flex-1"> */}
<FormField
label="Version"
required
@ -327,8 +315,8 @@ export const NewModuleModal = ({
error={errors.version?.message}
{...register('version')}
/>
</div>
<div className="flex-1">
{/* </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>

View File

@ -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,7 +285,6 @@ export const NewRoleModal = ({
});
} 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) ||
@ -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>
);

View File

@ -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>

View File

@ -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.';

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -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;
}