diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 1d5d73b..3323438 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -1,15 +1,14 @@ import { useEffect, useState, useRef } from "react"; import type { ReactElement } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useFieldArray } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Loader2 } from "lucide-react"; +import { Loader2, Plus, Trash2 } from "lucide-react"; import { Modal, FormField, FormSelect, PaginatedSelect, - MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -30,10 +29,26 @@ const editUserSchema = z.object({ message: "Status is required", }), tenant_id: z.string().min(1, "Tenant is required"), - role_ids: z.array(z.string()).min(1, "At least one role 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" } + ), department_id: z.string().optional(), designation_id: z.string().optional(), - module_ids: z.array(z.string()).optional(), category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"), supplier_id: z.string().optional().nullable(), }); @@ -70,6 +85,7 @@ export const EditUserModal = ({ const loadedUserIdRef = useRef(null); const { + control, register, handleSubmit, setValue, @@ -79,15 +95,18 @@ export const EditUserModal = ({ clearErrors, formState: { errors }, } = useForm({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolver: zodResolver(editUserSchema) as any, + // @ts-ignore + resolver: zodResolver(editUserSchema), + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "role_module_combinations", }); const statusValue = watch("status"); - const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); - const moduleIdsValue = watch("module_ids"); const categoryValue = watch("category"); const supplierIdValue = watch("supplier_id"); @@ -213,14 +232,37 @@ export const EditUserModal = ({ loadedUserIdRef.current = userId; const tenantId = user.tenant?.id || user.tenant_id || ""; - const roleIds = - user.roles?.map((r) => r.id) || - (user.role_id ? [user.role_id] : []); - const roleOptions = - user.roles?.map((r) => ({ value: r.id, label: r.name })) || - (user.role?.id - ? [{ value: user.role.id, label: user.role.name }] - : []); + + const roleOptions: { value: string; label: string }[] = []; + const moduleOptions: { value: string; label: string }[] = []; + + let initialCombinations = [{ role_id: "", module_id: null as string | null }]; + + if (user.role_module_combinations && user.role_module_combinations.length > 0) { + initialCombinations = user.role_module_combinations.map(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 }; + }); + } 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 })); + } + if (user.roles?.length) { + user.roles.forEach(r => roleOptions.push({ value: r.id, label: r.name })); + } else if (user.role?.id) { + roleOptions.push({ value: user.role.id, label: user.role.name }); + } + if (user.modules?.length) { + user.modules.forEach(m => moduleOptions.push({ value: m.id, label: m.name })); + } + } const departmentId = user.department?.id || user.department_id || ""; @@ -252,10 +294,9 @@ export const EditUserModal = ({ last_name: user.last_name, status: user.status, tenant_id: defaultTenantId || tenantId, - role_ids: roleIds, + role_module_combinations: initialCombinations, department_id: departmentId, designation_id: designationId, - module_ids: user.modules?.map((m) => m.id) || [], category: (user.category as "tenant_user" | "supplier_user") || "tenant_user", @@ -285,10 +326,8 @@ export const EditUserModal = ({ } } - if (user.modules) { - setInitialModuleOptions( - user.modules.map((m) => ({ value: m.id, label: m.name })), - ); + if (moduleOptions.length > 0) { + setInitialModuleOptions(moduleOptions); } if (defaultTenantId) { @@ -317,10 +356,9 @@ export const EditUserModal = ({ last_name: "", status: "active", tenant_id: defaultTenantId || "", - role_ids: [], + role_module_combinations: [{ role_id: "", module_id: null }], department_id: "", designation_id: "", - module_ids: [], category: "tenant_user", supplier_id: null, }); @@ -365,10 +403,19 @@ export const EditUserModal = ({ const validationErrors = error.response.data.details; validationErrors.forEach( (detail: { path: string; message: string }) => { - setError(detail.path as keyof EditUserFormData, { - type: "server", - message: detail.message, - }); + if ( + detail.path === "email" || + detail.path === "first_name" || + detail.path === "last_name" || + detail.path === "status" || + detail.path === "tenant_id" || + detail.path === "role_module_combinations" + ) { + setError(detail.path as keyof EditUserFormData, { + type: "server", + message: detail.message, + }); + } }, ); } else { @@ -526,17 +573,7 @@ export const EditUserModal = ({ )} -
- setValue("role_ids", value)} - onLoadOptions={loadRoles} - initialOptions={initialRoleOptions} - error={errors.role_ids?.message} - /> +
-
- setValue("module_ids", value)} - onLoadOptions={loadModules} - initialOptions={initialModuleOptions} - error={errors.module_ids?.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`); + + // Extract specific label if available from initial options + const getRoleLabel = (val: string) => { + const opt = initialRoleOptions.find(o => o.value === val); + return opt ? opt.label : undefined; + }; + + const getModuleLabel = (val: string) => { + const opt = initialModuleOptions.find(o => o.value === val); + return opt ? opt.label : undefined; + }; + + return ( +
+
+ setValue(`role_module_combinations.${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} + /> + setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })} + onLoadOptions={loadModules} + initialOption={moduleIdValue ? { value: moduleIdValue, label: getModuleLabel(moduleIdValue) || moduleIdValue } : undefined} + error={errors.role_module_combinations?.[index]?.module_id?.message} + /> +
+ {fields.length > 1 && ( + + )} +
+ ); + })} +
)} diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 59e6a92..35827c1 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -1,14 +1,14 @@ import { useEffect } from "react"; import type { ReactElement } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useFieldArray } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { Plus, Trash2 } from "lucide-react"; import { Modal, FormField, FormSelect, PaginatedSelect, - MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -36,10 +36,26 @@ const newUserSchema = z auth_provider: z.enum(["local"], { message: "Auth provider is required", }), - role_ids: z.array(z.string()).min(1, "At least one role 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" } + ), department_id: z.string().optional(), designation_id: z.string().optional(), - module_ids: z.array(z.string()).optional(), category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"), supplier_id: z.string().optional().nullable(), }) @@ -72,6 +88,7 @@ export const NewUserModal = ({ defaultTenantId, }: NewUserModalProps): ReactElement | null => { const { + control, register, handleSubmit, setValue, @@ -84,20 +101,22 @@ export const NewUserModal = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any resolver: zodResolver(newUserSchema) as any, defaultValues: { - role_ids: [], + role_module_combinations: [{ role_id: "", module_id: null }], department_id: "", designation_id: "", - module_ids: [], category: "tenant_user" as const, supplier_id: null, }, }); + const { fields, append, remove } = useFieldArray({ + control, + name: "role_module_combinations", + }); + const statusValue = watch("status"); - const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); - const moduleIdsValue = watch("module_ids"); const categoryValue = watch("category"); const supplierIdValue = watch("supplier_id"); @@ -115,10 +134,9 @@ export const NewUserModal = ({ last_name: "", status: "active", auth_provider: "local", - role_ids: [], + role_module_combinations: [{ role_id: "", module_id: null }], department_id: "", designation_id: "", - module_ids: [], category: "tenant_user", supplier_id: null, }); @@ -244,8 +262,7 @@ export const NewUserModal = ({ detail.path === "last_name" || detail.path === "status" || detail.path === "auth_provider" || - detail.path === "role_ids" || - detail.path === "module_ids" + detail.path === "role_module_combinations" ) { setError(detail.path as keyof NewUserFormData, { type: "server", @@ -421,18 +438,7 @@ export const NewUserModal = ({ )}
- {/* Role and Status Row */} -
- setValue("role_ids", value)} - onLoadOptions={loadRoles} - error={errors.role_ids?.message} - /> - +
-
- setValue("module_ids", value)} - onLoadOptions={loadModules} - error={errors.module_ids?.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 && ( + + )} +
+ ); + })} +
diff --git a/src/components/shared/ViewUserModal.tsx b/src/components/shared/ViewUserModal.tsx index 45ceeb1..a854482 100644 --- a/src/components/shared/ViewUserModal.tsx +++ b/src/components/shared/ViewUserModal.tsx @@ -141,7 +141,17 @@ export const ViewUserModal = ({ Roles
- {user.roles && user.roles.length > 0 ? ( + {user.role_module_combinations && user.role_module_combinations.length > 0 ? ( + user.role_module_combinations.map((combo, idx) => ( + + {combo.role_name} {combo.module_name && `(${combo.module_name})`} + + )) + ) : user.roles && user.roles.length > 0 ? ( user.roles.map((role) => ( => { @@ -181,7 +181,7 @@ export const UsersTable = ({ status: "active" | "suspended" | "deleted"; auth_provider?: string; tenant_id: string; - role_ids: string[]; + role_module_combinations: { role_id: string; module_id?: string | null }[]; department_id?: string; designation_id?: string; }, @@ -270,7 +270,17 @@ export const UsersTable = ({ label: "Role", render: (user) => (
- {user.roles && user.roles.length > 0 ? ( + {user.role_module_combinations && user.role_module_combinations.length > 0 ? ( + user.role_module_combinations.map((combo, idx) => ( + + {combo.role_name} {combo.module_name && `(${combo.module_name})`} + + )) + ) : user.roles && user.roles.length > 0 ? ( user.roles.map((role) => ( { last_name: string; status: "active" | "suspended" | "deleted"; auth_provider: "local"; - role_ids: string[]; + role_module_combinations: { role_id: string; module_id?: string | null }[]; department_id?: string; designation_id?: string; }): Promise => { @@ -179,7 +179,7 @@ const Users = (): ReactElement => { last_name: string; status: "active" | "suspended" | "deleted"; tenant_id: string; - role_ids: string[]; + role_module_combinations: { role_id: string; module_id?: string | null }[]; department_id?: string; designation_id?: string; }, @@ -264,7 +264,17 @@ const Users = (): ReactElement => { label: "Role", render: (user) => (
- {user.roles && user.roles.length > 0 ? ( + {user.role_module_combinations && user.role_module_combinations.length > 0 ? ( + user.role_module_combinations.map((combo, idx) => ( + + {combo.role_name} {combo.module_name && `(${combo.module_name})`} + + )) + ) : user.roles && user.roles.length > 0 ? ( user.roles.map((role) => (