feat: Enable granular user role assignments with optional module association using role_module_combinations.

This commit is contained in:
Yashwin 2026-03-20 11:16:07 +05:30
parent a6ef7e6bee
commit 20d802555e
6 changed files with 288 additions and 90 deletions

View File

@ -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<string | null>(null);
const {
control,
register,
handleSubmit,
setValue,
@ -79,15 +95,18 @@ export const EditUserModal = ({
clearErrors,
formState: { errors },
} = useForm<EditUserFormData>({
// 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 = ({
)}
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<MultiselectPaginatedSelect
label="Assign Role"
required
placeholder="Select Roles"
value={roleIdsValue || []}
onValueChange={(value) => setValue("role_ids", value)}
onLoadOptions={loadRoles}
initialOptions={initialRoleOptions}
error={errors.role_ids?.message}
/>
<div className="pb-4">
<FormSelect
label="Status"
required
@ -553,16 +590,77 @@ export const EditUserModal = ({
/>
</div>
<div className="pb-4">
<MultiselectPaginatedSelect
label="Assign Modules"
placeholder="Select Modules"
value={moduleIdsValue || []}
onValueChange={(value) => setValue("module_ids", value)}
onLoadOptions={loadModules}
initialOptions={initialModuleOptions}
error={errors.module_ids?.message}
/>
<div className="mt-2 mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">Role & Module Assignments</label>
<button
type="button"
onClick={() => append({ role_id: "", module_id: null })}
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
{(errors.role_module_combinations as any)?.message && (
<p className="text-sm text-red-500 mb-2">
{(errors.role_module_combinations as any).message}
</p>
)}
<div className="space-y-3">
{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 (
<div key={field.id} className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative">
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect
label={`Role ${index + 1}`}
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => 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}
/>
<PaginatedSelect
label="Module (Optional)"
placeholder="Select Module"
value={moduleIdValue || ""}
onValueChange={(value) => 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}
/>
</div>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="mt-8 p-2 text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Remove Assignment"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
</div>
</div>
)}

View File

@ -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 = ({
)}
</div>
{/* Role and Status Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<MultiselectPaginatedSelect
label="Assign Role"
required
placeholder="Select Roles"
value={roleIdsValue || []}
onValueChange={(value) => setValue("role_ids", value)}
onLoadOptions={loadRoles}
error={errors.role_ids?.message}
/>
<div className="pb-4">
<FormSelect
label="Status"
required
@ -446,15 +452,64 @@ export const NewUserModal = ({
/>
</div>
<div className="pb-4">
<MultiselectPaginatedSelect
label="Assign Modules"
placeholder="Select Modules"
value={moduleIdsValue || []}
onValueChange={(value) => setValue("module_ids", value)}
onLoadOptions={loadModules}
error={errors.module_ids?.message}
/>
<div className="mt-2 mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">Role & Module Assignments</label>
<button
type="button"
onClick={() => append({ role_id: "", module_id: null })}
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
{(errors.role_module_combinations as any)?.message && (
<p className="text-sm text-red-500 mb-2">
{(errors.role_module_combinations as any).message}
</p>
)}
<div className="space-y-3">
{fields.map((field, index) => {
const roleIdValue = watch(`role_module_combinations.${index}.role_id`);
const moduleIdValue = watch(`role_module_combinations.${index}.module_id`);
return (
<div key={field.id} className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative">
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect
label={`Role ${index + 1}`}
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.role_id`, value, { shouldValidate: true })}
onLoadOptions={loadRoles}
error={errors.role_module_combinations?.[index]?.role_id?.message}
/>
<PaginatedSelect
label="Module (Optional)"
placeholder="Select Module"
value={moduleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })}
onLoadOptions={loadModules}
error={errors.role_module_combinations?.[index]?.module_id?.message}
/>
</div>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="mt-8 p-2 text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Remove Assignment"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
</div>
</div>
</form>

View File

@ -141,7 +141,17 @@ export const ViewUserModal = ({
Roles
</label>
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
</span>
))
) : user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}

View File

@ -132,7 +132,7 @@ export const UsersTable = ({
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<void> => {
@ -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) => (
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
</span>
))
) : user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}

View File

@ -134,7 +134,7 @@ const Users = (): ReactElement => {
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<void> => {
@ -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) => (
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
</span>
))
) : user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}

View File

@ -1,3 +1,15 @@
export interface RoleModuleCombination {
role_id: string;
module_id?: string | null;
}
export interface UserRoleModuleCombination {
role_id: string;
role_name: string;
module_id: string | null;
module_name: string | null;
}
export interface User {
id: string;
email: string;
@ -33,6 +45,7 @@ export interface User {
id: string;
name: string;
}[];
role_module_combinations?: UserRoleModuleCombination[];
category?: 'tenant_user' | 'supplier_user';
supplier_id?: string | null;
created_at: string;
@ -63,6 +76,7 @@ export interface CreateUserRequest {
tenant_id?: string;
role_id?: string;
role_ids?: string[];
role_module_combinations?: RoleModuleCombination[];
department_id?: string;
designation_id?: string;
module_ids?: string[];
@ -90,6 +104,7 @@ export interface UpdateUserRequest {
tenant_id: string;
role_id?: string;
role_ids?: string[];
role_module_combinations?: RoleModuleCombination[];
department_id?: string;
designation_id?: string;
module_ids?: string[];