From 320277b536c93384bacdac35b92fae37b163114a Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 24 Mar 2026 13:02:19 +0530 Subject: [PATCH] feat: Refactor user role assignments in modals to use `role_module_assignments` with improved validation and module association handling. --- src/components/shared/EditUserModal.tsx | 131 ++++-- src/components/shared/NewUserModal.tsx | 541 +++++++++--------------- 2 files changed, 276 insertions(+), 396 deletions(-) diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 3323438..1d02aa4 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -9,6 +9,7 @@ import { FormField, FormSelect, PaginatedSelect, + MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -20,6 +21,11 @@ import { supplierService } from "@/services/supplier-service"; import { useAppSelector } from "@/hooks/redux-hooks"; import type { User } from "@/types/user"; +const assignmentSchema = z.object({ + role_id: z.string().min(1, "Role is required"), + module_ids: z.array(z.string().min(1)).min(1, "At least one module is required"), +}); + // Validation schema const editUserSchema = z.object({ email: z.email({ message: "Please enter a valid email address" }), @@ -29,24 +35,22 @@ const editUserSchema = z.object({ message: "Status is required", }), tenant_id: z.string().min(1, "Tenant is required"), - role_module_combinations: z.array( - z.object({ - role_id: z.string().min(1, "Role is required"), - module_id: z.string().nullable().optional(), - }) - ).min(1, "At least one role assignment is required") - .refine( - (combinations) => { - const seen = new Set(); - for (const combo of combinations) { - const key = `${combo.role_id}-${combo.module_id || "global"}`; - if (seen.has(key)) return false; - seen.add(key); - } - return true; - }, - { message: "Duplicate role-module combinations are not allowed" } - ), + role_module_assignments: z.array(assignmentSchema).min(1, "At least one role assignment is required") + .superRefine((assignments, ctx) => { + const seenModules = new Set(); + assignments.forEach((assignment, rowIndex) => { + assignment.module_ids.forEach((moduleId) => { + if (seenModules.has(moduleId)) { + ctx.addIssue({ + code: "custom", + path: [rowIndex, "module_ids"], + message: "A module can only be assigned to one role", + }); + } + seenModules.add(moduleId); + }); + }); + }), department_id: z.string().optional(), designation_id: z.string().optional(), category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"), @@ -54,13 +58,16 @@ const editUserSchema = z.object({ }); type EditUserFormData = z.infer; +type UpdateUserPayload = Omit & { + role_module_combinations: { role_id: string; module_id: string }[]; +}; interface EditUserModalProps { isOpen: boolean; onClose: () => void; userId: string | null; onLoadUser: (id: string) => Promise; - onSubmit: (id: string, data: EditUserFormData) => Promise; + onSubmit: (id: string, data: UpdateUserPayload) => Promise; isLoading?: boolean; defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field } @@ -101,7 +108,7 @@ export const EditUserModal = ({ const { fields, append, remove } = useFieldArray({ control, - name: "role_module_combinations", + name: "role_module_assignments", }); const statusValue = watch("status"); @@ -236,23 +243,32 @@ export const EditUserModal = ({ const roleOptions: { value: string; label: string }[] = []; const moduleOptions: { value: string; label: string }[] = []; - let initialCombinations = [{ role_id: "", module_id: null as string | null }]; + let initialAssignments = [{ role_id: "", module_ids: [] as string[] }]; if (user.role_module_combinations && user.role_module_combinations.length > 0) { - initialCombinations = user.role_module_combinations.map(c => { + const grouped = new Map(); + user.role_module_combinations.forEach((c) => { if (c.role_id && c.role_name && !roleOptions.some(o => o.value === c.role_id)) { roleOptions.push({ value: c.role_id, label: c.role_name }); } if (c.module_id && c.module_name && !moduleOptions.some(o => o.value === c.module_id)) { moduleOptions.push({ value: c.module_id, label: c.module_name }); } - return { role_id: c.role_id, module_id: c.module_id || null }; + if (c.module_id) { + const existing = grouped.get(c.role_id) || []; + existing.push(c.module_id); + grouped.set(c.role_id, existing); + } }); + initialAssignments = Array.from(grouped.entries()).map(([role_id, module_ids]) => ({ + role_id, + module_ids, + })); } else { // Fallback for older format const r_ids = user.roles?.map(r => r.id) || (user.role_id ? [user.role_id] : []); if (r_ids.length > 0) { - initialCombinations = r_ids.map(id => ({ role_id: id, module_id: null })); + initialAssignments = r_ids.map(id => ({ role_id: id, module_ids: [] })); } if (user.roles?.length) { user.roles.forEach(r => roleOptions.push({ value: r.id, label: r.name })); @@ -294,7 +310,7 @@ export const EditUserModal = ({ last_name: user.last_name, status: user.status, tenant_id: defaultTenantId || tenantId, - role_module_combinations: initialCombinations, + role_module_assignments: initialAssignments, department_id: departmentId, designation_id: designationId, category: @@ -356,7 +372,7 @@ export const EditUserModal = ({ last_name: "", status: "active", tenant_id: defaultTenantId || "", - role_module_combinations: [{ role_id: "", module_id: null }], + role_module_assignments: [{ role_id: "", module_ids: [] }], department_id: "", designation_id: "", category: "tenant_user", @@ -394,13 +410,21 @@ export const EditUserModal = ({ data.supplier_id = null; } - await onSubmit(userId, data); + const { role_module_assignments, ...submitData } = data; + const role_module_combinations = role_module_assignments.flatMap((row) => + row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })), + ); + await onSubmit(userId, { ...submitData, role_module_combinations }); } catch (error: any) { if ( error?.response?.data?.details && Array.isArray(error.response.data.details) ) { const validationErrors = error.response.data.details; + setError("root", { + type: "server", + message: validationErrors[0]?.message || "Validation failed", + }); validationErrors.forEach( (detail: { path: string; message: string }) => { if ( @@ -409,6 +433,7 @@ export const EditUserModal = ({ detail.path === "last_name" || detail.path === "status" || detail.path === "tenant_id" || + detail.path === "role_module_assignments" || detail.path === "role_module_combinations" ) { setError(detail.path as keyof EditUserFormData, { @@ -441,6 +466,17 @@ export const EditUserModal = ({ } }; + const getModuleError = (index: number): string | undefined => { + const fieldError: any = errors.role_module_assignments?.[index]?.module_ids; + if (!fieldError) return undefined; + if (typeof fieldError.message === "string") return fieldError.message; + if (Array.isArray(fieldError)) { + const firstChild = fieldError.find((e: any) => e?.message); + return firstChild?.message; + } + return undefined; + }; + return ( Role & Module Assignments - {(errors.role_module_combinations as any)?.message && ( + {(errors.role_module_assignments as any)?.message && (

- {(errors.role_module_combinations as any).message} + {(errors.role_module_assignments as any).message}

)}
{fields.map((field, index) => { - const roleIdValue = watch(`role_module_combinations.${index}.role_id`); - const moduleIdValue = watch(`role_module_combinations.${index}.module_id`); + const roleIdValue = watch(`role_module_assignments.${index}.role_id`); + const moduleIdsValue = watch(`role_module_assignments.${index}.module_ids`) || []; // Extract specific label if available from initial options const getRoleLabel = (val: string) => { @@ -619,32 +655,35 @@ export const EditUserModal = ({ return opt ? opt.label : undefined; }; - const getModuleLabel = (val: string) => { - const opt = initialModuleOptions.find(o => o.value === val); - return opt ? opt.label : undefined; - }; + const initialSelectedModules = moduleIdsValue + .map((val) => { + const opt = initialModuleOptions.find((o) => o.value === val); + return opt ? { value: opt.value, label: opt.label } : null; + }) + .filter((opt): opt is { value: string; label: string } => Boolean(opt)); return (
setValue(`role_module_combinations.${index}.role_id`, value, { shouldValidate: true })} + onValueChange={(value) => setValue(`role_module_assignments.${index}.role_id`, value, { shouldValidate: true })} onLoadOptions={loadRoles} initialOption={roleIdValue ? { value: roleIdValue, label: getRoleLabel(roleIdValue) || roleIdValue } : undefined} - error={errors.role_module_combinations?.[index]?.role_id?.message} + error={errors.role_module_assignments?.[index]?.role_id?.message} /> - setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })} + setValue(`role_module_assignments.${index}.module_ids`, value, { shouldValidate: true })} onLoadOptions={loadModules} - initialOption={moduleIdValue ? { value: moduleIdValue, label: getModuleLabel(moduleIdValue) || moduleIdValue } : undefined} - error={errors.role_module_combinations?.[index]?.module_id?.message} + initialOptions={initialSelectedModules} + error={getModuleError(index)} />
{fields.length > 1 && ( diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 35827c1..c738a22 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -9,6 +9,7 @@ import { FormField, FormSelect, PaginatedSelect, + MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -19,41 +20,36 @@ import { moduleService } from "@/services/module-service"; import { supplierService } from "@/services/supplier-service"; import { useAppSelector } from "@/hooks/redux-hooks"; -// Validation schema +const assignmentSchema = z.object({ + role_id: z.string().min(1, "Role is required"), + module_ids: z.array(z.string().min(1)).min(1, "At least one module is required"), +}); + const newUserSchema = z .object({ email: z.email({ message: "Please enter a valid email address" }), - password: z - .string() - .min(1, "Password is required") - .min(6, "Password must be at least 6 characters"), + password: z.string().min(1, "Password is required").min(6, "Password must be at least 6 characters"), confirmPassword: z.string().min(1, "Confirm password is required"), first_name: z.string().min(1, "First name is required"), last_name: z.string().min(1, "Last name is required"), - status: z.enum(["active", "suspended", "deleted"], { - message: "Status is required", - }), - auth_provider: z.enum(["local"], { - message: "Auth provider is required", - }), - role_module_combinations: z.array( - z.object({ - role_id: z.string().min(1, "Role is required"), - module_id: z.string().nullable().optional(), - }) - ).min(1, "At least one role assignment is required") - .refine( - (combinations) => { - const seen = new Set(); - for (const combo of combinations) { - const key = `${combo.role_id}-${combo.module_id || "global"}`; - if (seen.has(key)) return false; - seen.add(key); - } - return true; - }, - { message: "Duplicate role-module combinations are not allowed" } - ), + status: z.enum(["active", "suspended", "deleted"], { message: "Status is required" }), + auth_provider: z.enum(["local"], { message: "Auth provider is required" }), + role_module_assignments: z.array(assignmentSchema).min(1, "At least one role assignment is required") + .superRefine((assignments, ctx) => { + const used = new Set(); + assignments.forEach((assignment, rowIndex) => { + assignment.module_ids.forEach((moduleId) => { + if (used.has(moduleId)) { + ctx.addIssue({ + code: "custom", + path: [rowIndex, "module_ids"], + message: "A module can only be assigned to one role", + }); + } + used.add(moduleId); + }); + }); + }), department_id: z.string().optional(), designation_id: z.string().optional(), category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"), @@ -65,13 +61,16 @@ const newUserSchema = z }); type NewUserFormData = z.infer; +type CreateUserPayload = Omit & { + role_module_combinations: { role_id: string; module_id: string }[]; +}; interface NewUserModalProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: Omit) => Promise; + onSubmit: (data: CreateUserPayload) => Promise; isLoading?: boolean; - defaultTenantId?: string; // If provided, filter roles by tenant_id + defaultTenantId?: string; } const statusOptions = [ @@ -87,31 +86,21 @@ export const NewUserModal = ({ isLoading = false, defaultTenantId, }: NewUserModalProps): ReactElement | null => { - const { - control, - register, - handleSubmit, - setValue, - watch, - reset, - setError, - clearErrors, - formState: { errors }, - } = useForm({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolver: zodResolver(newUserSchema) as any, - defaultValues: { - role_module_combinations: [{ role_id: "", module_id: null }], - department_id: "", - designation_id: "", - category: "tenant_user" as const, - supplier_id: null, - }, - }); + const { control, register, handleSubmit, setValue, watch, reset, setError, clearErrors, formState: { errors } } = + useForm({ + resolver: zodResolver(newUserSchema) as any, + defaultValues: { + role_module_assignments: [{ role_id: "", module_ids: [] }], + department_id: "", + designation_id: "", + category: "tenant_user", + supplier_id: null, + }, + }); const { fields, append, remove } = useFieldArray({ control, - name: "role_module_combinations", + name: "role_module_assignments", }); const statusValue = watch("status"); @@ -123,7 +112,6 @@ export const NewUserModal = ({ const roles = useAppSelector((state) => state.auth.roles); const isSuperAdmin = roles.includes("super_admin"); - // Reset form when modal closes useEffect(() => { if (!isOpen) { reset({ @@ -134,7 +122,7 @@ export const NewUserModal = ({ last_name: "", status: "active", auth_provider: "local", - role_module_combinations: [{ role_id: "", module_id: null }], + role_module_assignments: [{ role_id: "", module_ids: [] }], department_id: "", designation_id: "", category: "tenant_user", @@ -144,158 +132,90 @@ export const NewUserModal = ({ } }, [isOpen, reset, clearErrors]); - // Load roles for dropdown const loadRoles = async (page: number, limit: number) => { const response = defaultTenantId ? await roleService.getByTenant(defaultTenantId, page, limit) : await roleService.getAll(page, limit); return { - options: response.data.map((role) => ({ - value: role.id, - label: role.name, - })), + options: response.data.map((role) => ({ value: role.id, label: role.name })), pagination: response.pagination, }; }; - const loadDepartments = async () => { - const response = await departmentService.list(defaultTenantId, { - active_only: true, - }); - return { - options: response.data.map((dept) => ({ - value: dept.id, - label: dept.name, - })), - pagination: { - page: 1, - limit: response.data.length, - total: response.data.length, - totalPages: 1, - hasMore: false, - }, - }; - }; - - const loadDesignations = async () => { - const response = await designationService.list(defaultTenantId, { - active_only: true, - }); - return { - options: response.data.map((desig) => ({ - value: desig.id, - label: desig.name, - })), - pagination: { - page: 1, - limit: response.data.length, - total: response.data.length, - totalPages: 1, - hasMore: false, - }, - }; - }; - const loadModules = async (page: number, limit: number) => { const tenantId = isSuperAdmin ? defaultTenantId : undefined; const response = await moduleService.getAvailable(page, limit, tenantId); return { - options: response.data.map((module) => ({ - value: module.id, - label: module.name, - })), + options: response.data.map((module) => ({ value: module.id, label: module.name })), pagination: response.pagination, }; }; + const loadDepartments = async () => { + const response = await departmentService.list(defaultTenantId, { active_only: true }); + return { + options: response.data.map((dept) => ({ value: dept.id, label: dept.name })), + pagination: { page: 1, limit: response.data.length, total: response.data.length, totalPages: 1, hasMore: false }, + }; + }; + + const loadDesignations = async () => { + const response = await designationService.list(defaultTenantId, { active_only: true }); + return { + options: response.data.map((desig) => ({ value: desig.id, label: desig.name })), + pagination: { page: 1, limit: response.data.length, total: response.data.length, totalPages: 1, hasMore: false }, + }; + }; + const loadSuppliers = async (page: number, limit: number) => { - const response = await supplierService.list({ - tenantId: defaultTenantId, - limit, - offset: (page - 1) * limit, - }); + const response = await supplierService.list({ tenantId: defaultTenantId, limit, offset: (page - 1) * limit }); const total = response.pagination?.total ?? response.data?.length ?? 0; return { - options: (response.data || []).map((supplier: any) => ({ - value: supplier.id, - label: supplier.name, - })), - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) || 1, - hasMore: page * limit < total, - }, + options: (response.data || []).map((supplier: any) => ({ value: supplier.id, label: supplier.name })), + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) || 1, hasMore: page * limit < total }, }; }; const handleFormSubmit = async (data: NewUserFormData): Promise => { clearErrors(); try { - const { confirmPassword, ...submitData } = data; - // Normalize empty strings to null for optional UUID fields + const { confirmPassword, role_module_assignments, ...submitData } = data; + const role_module_combinations = role_module_assignments.flatMap((row) => + row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })), + ); + if (!submitData.department_id) submitData.department_id = undefined; if (!submitData.designation_id) submitData.designation_id = undefined; - - // If category is tenant_user, supplier_id should always be null - if (submitData.category === "tenant_user") { - submitData.supplier_id = null; - } else if (!submitData.supplier_id) { - submitData.supplier_id = null; - } - - await onSubmit(submitData); + if (submitData.category === "tenant_user") submitData.supplier_id = null; + else if (!submitData.supplier_id) submitData.supplier_id = null; + + await onSubmit({ ...submitData, role_module_combinations }); } 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 === "email" || - detail.path === "password" || - detail.path === "first_name" || - detail.path === "last_name" || - detail.path === "status" || - detail.path === "auth_provider" || - detail.path === "role_module_combinations" - ) { - setError(detail.path as keyof NewUserFormData, { - type: "server", - message: detail.message, - }); - } - }, - ); - } 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) || - (typeof errorObj === "string" ? errorObj : null) || - error?.response?.data?.message || - error?.message || - "Failed to create user. Please try again."; - setError("root", { - type: "server", - message: - typeof errorMessage === "string" - ? errorMessage - : "Failed to create user. Please try again.", - }); + if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { + const first = error.response.data.details[0]; + setError("root", { type: "server", message: first?.message || "Validation failed" }); + return; } + const message = + error?.response?.data?.error?.message || + error?.response?.data?.message || + error?.message || + "Failed to create user. Please try again."; + setError("root", { type: "server", message }); } }; + const getModuleError = (index: number): string | undefined => { + const fieldError: any = errors.role_module_assignments?.[index]?.module_ids; + if (!fieldError) return undefined; + if (typeof fieldError.message === "string") return fieldError.message; + if (Array.isArray(fieldError)) { + const firstChild = fieldError.find((e: any) => e?.message); + return firstChild?.message; + } + return undefined; + }; + return ( - + Cancel
- {/* General Error Display */} {errors.root && (

{errors.root.message}

)} -
- {/* Email */} - +
+ + +
+
+ + +
+
+ setValue("department_id", value)} onLoadOptions={loadDepartments} error={errors.department_id?.message} /> + setValue("designation_id", value)} onLoadOptions={loadDesignations} error={errors.designation_id?.message} /> +
+
+ { + const category = value as "tenant_user" | "supplier_user"; + setValue("category", category, { shouldValidate: true }); + if (category === "tenant_user") setValue("supplier_id", null); + }} + error={errors.category?.message} /> - - {/* First Name and Last Name Row */} -
- - - -
- - {/* Password and Confirm Password Row */} -
- - - -
- -
+ {categoryValue === "supplier_user" && ( setValue("department_id", value)} - onLoadOptions={loadDepartments} - error={errors.department_id?.message} - /> - - setValue("designation_id", value)} - onLoadOptions={loadDesignations} - error={errors.designation_id?.message} - /> -
- - {/* User Category */} -
- { - const category = value as "tenant_user" | "supplier_user"; - setValue("category", category, { shouldValidate: true }); - if (category === "tenant_user") { - setValue("supplier_id", null); - } - }} - error={errors.category?.message} + placeholder="Select Supplier" + value={supplierIdValue || ""} + onValueChange={(value) => setValue("supplier_id", value)} + onLoadOptions={loadSuppliers} + error={errors.supplier_id?.message} /> + )} +
+
+ setValue("status", value as "active" | "suspended" | "deleted")} + error={errors.status?.message} + /> +
- {categoryValue === "supplier_user" && ( - setValue("supplier_id", value)} - onLoadOptions={loadSuppliers} - error={errors.supplier_id?.message} - /> - )} +
+
+ +
- -
- - setValue("status", value as "active" | "suspended" | "deleted") - } - error={errors.status?.message} - /> -
- -
-
- - -
- - {(errors.role_module_combinations as any)?.message && ( -

- {(errors.role_module_combinations as any).message} -

- )} - -
- {fields.map((field, index) => { - const roleIdValue = watch(`role_module_combinations.${index}.role_id`); - const moduleIdValue = watch(`role_module_combinations.${index}.module_id`); - - return ( -
-
- setValue(`role_module_combinations.${index}.role_id`, value, { shouldValidate: true })} - onLoadOptions={loadRoles} - error={errors.role_module_combinations?.[index]?.role_id?.message} - /> - setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })} - onLoadOptions={loadModules} - error={errors.role_module_combinations?.[index]?.module_id?.message} - /> -
- {fields.length > 1 && ( - - )} + {(errors.role_module_assignments as any)?.message && ( +

{(errors.role_module_assignments as any).message}

+ )} +
+ {fields.map((field, index) => { + const roleIdValue = watch(`role_module_assignments.${index}.role_id`); + const moduleIdsValue = watch(`role_module_assignments.${index}.module_ids`) || []; + return ( +
+
+ setValue(`role_module_assignments.${index}.role_id`, value, { shouldValidate: true })} + onLoadOptions={loadRoles} + error={errors.role_module_assignments?.[index]?.role_id?.message} + /> + setValue(`role_module_assignments.${index}.module_ids`, value, { shouldValidate: true })} + onLoadOptions={loadModules} + error={getModuleError(index)} + />
- ); - })} -
+ {fields.length > 1 && ( + + )} +
+ ); + })}