feat: add tenant admin support to EditUserModal and dynamic form validation

This commit is contained in:
Yashwin 2026-07-09 15:58:25 +05:30
parent 04003a2332
commit f7eb25f874
4 changed files with 174 additions and 124 deletions

View File

@ -69,7 +69,7 @@ const editUserSchema = z.object({
type EditUserFormData = z.infer<typeof editUserSchema>; type EditUserFormData = z.infer<typeof editUserSchema>;
type UpdateUserPayload = Omit<EditUserFormData, "role_module_assignments"> & { type UpdateUserPayload = Omit<EditUserFormData, "role_module_assignments"> & {
role_module_combinations: { role_id: string; module_id: string }[]; role_module_combinations?: { role_id: string; module_id?: string | null }[];
}; };
interface EditUserModalProps { interface EditUserModalProps {
@ -100,6 +100,8 @@ export const EditUserModal = ({
const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false); const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const loadedUserIdRef = useRef<string | null>(null); const loadedUserIdRef = useRef<string | null>(null);
const [isTenantAdmin, setIsTenantAdmin] = useState<boolean>(false);
const [selectedUserTenantId, setSelectedUserTenantId] = useState<string>("");
const { const {
control, control,
@ -113,7 +115,20 @@ export const EditUserModal = ({
formState: { errors }, formState: { errors },
} = useForm<EditUserFormData>({ } = useForm<EditUserFormData>({
// @ts-ignore // @ts-ignore
resolver: zodResolver(editUserSchema), resolver: zodResolver(
isTenantAdmin
? editUserSchema.extend({
role_module_assignments: z
.array(
z.object({
role_id: z.string().optional(),
module_ids: z.array(z.string()).optional(),
})
)
.optional(),
})
: editUserSchema
),
}); });
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
@ -127,8 +142,6 @@ export const EditUserModal = ({
const categoryValue = watch("category"); const categoryValue = watch("category");
const supplierIdValue = watch("supplier_id"); const supplierIdValue = watch("supplier_id");
const rolesFromAuth = useAppSelector((state) => state.auth.roles);
const isSuperAdmin = rolesFromAuth.includes("super_admin");
const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId); const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId);
const [initialRoleOptions, setInitialRoleOptions] = useState< const [initialRoleOptions, setInitialRoleOptions] = useState<
@ -152,8 +165,9 @@ export const EditUserModal = ({
// Load roles for dropdown - ensure selected role is included // Load roles for dropdown - ensure selected role is included
const loadRoles = async (page: number, limit: number) => { const loadRoles = async (page: number, limit: number) => {
const response = defaultTenantId const effectiveTenantId = selectedUserTenantId || defaultTenantId || tenantIdFromAuth;
? await roleService.getByTenant(defaultTenantId, page, limit, undefined, undefined, true) const response = effectiveTenantId
? await roleService.getByTenant(effectiveTenantId, page, limit, undefined, undefined, true)
: await roleService.getAll(page, limit, undefined, undefined, undefined, true); : await roleService.getAll(page, limit, undefined, undefined, undefined, true);
return { return {
options: response.data.map((role) => ({ options: response.data.map((role) => ({
@ -165,7 +179,8 @@ export const EditUserModal = ({
}; };
const loadDepartments = async () => { const loadDepartments = async () => {
const response = await departmentService.list(defaultTenantId, { const effectiveTenantId = selectedUserTenantId || defaultTenantId || tenantIdFromAuth;
const response = await departmentService.list(effectiveTenantId, {
active_only: true, active_only: true,
}); });
return { return {
@ -184,7 +199,8 @@ export const EditUserModal = ({
}; };
const loadDesignations = async () => { const loadDesignations = async () => {
const response = await designationService.list(defaultTenantId, { const effectiveTenantId = selectedUserTenantId || defaultTenantId || tenantIdFromAuth;
const response = await designationService.list(effectiveTenantId, {
active_only: true, active_only: true,
}); });
return { return {
@ -203,8 +219,8 @@ export const EditUserModal = ({
}; };
const loadModules = async (page: number, limit: number) => { const loadModules = async (page: number, limit: number) => {
const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth; const effectiveTenantId = selectedUserTenantId || defaultTenantId || tenantIdFromAuth;
const response = await moduleService.getAvailable(page, limit, tenantId); const response = await moduleService.getAvailable(page, limit, effectiveTenantId);
return { return {
options: response.data.map((module) => ({ options: response.data.map((module) => ({
value: module.id, value: module.id,
@ -215,8 +231,9 @@ export const EditUserModal = ({
}; };
const loadSuppliers = async (page: number, limit: number) => { const loadSuppliers = async (page: number, limit: number) => {
const effectiveTenantId = selectedUserTenantId || defaultTenantId || tenantIdFromAuth;
const response = await supplierService.list({ const response = await supplierService.list({
tenantId: defaultTenantId, tenantId: effectiveTenantId,
limit, limit,
offset: (page - 1) * limit, offset: (page - 1) * limit,
}); });
@ -344,6 +361,14 @@ export const EditUserModal = ({
}); });
} }
const isUserTenantAdmin =
user.roles?.some((r) => r.code === "tenant_admin") ||
user.role_module_combinations?.some((c) => c.role_code === "tenant_admin") ||
user.role?.code === "tenant_admin" ||
user.role_id === "tenant_admin";
setIsTenantAdmin(!!isUserTenantAdmin);
setSelectedUserTenantId(tenantId);
reset({ reset({
email: user.email, email: user.email,
first_name: user.first_name, first_name: user.first_name,
@ -406,6 +431,8 @@ export const EditUserModal = ({
setInitialDepartmentOption(null); setInitialDepartmentOption(null);
setInitialDesignationOption(null); setInitialDesignationOption(null);
setInitialSupplierOption(null); setInitialSupplierOption(null);
setIsTenantAdmin(false);
setSelectedUserTenantId("");
reset({ reset({
email: "", email: "",
first_name: "", first_name: "",
@ -448,12 +475,14 @@ export const EditUserModal = ({
} }
const { role_module_assignments, ...submitData } = data; const { role_module_assignments, ...submitData } = data;
const role_module_combinations = role_module_assignments.flatMap((row) => const role_module_combinations = isTenantAdmin
row.module_ids.map((module_id) => ({ ? undefined
role_id: row.role_id, : (role_module_assignments || []).flatMap((row) =>
module_id, (row.module_ids || []).map((module_id) => ({
})), role_id: row.role_id,
); module_id,
})),
);
await onSubmit(userId, { ...submitData, role_module_combinations }); await onSubmit(userId, { ...submitData, role_module_combinations });
} catch (error: any) { } catch (error: any) {
if ( if (
@ -672,118 +701,120 @@ export const EditUserModal = ({
/> />
</div> </div>
<div className="mt-2 mb-4"> {!isTenantAdmin && (
<div className="flex justify-between items-center mb-2"> <div className="mt-2 mb-4">
<label className="text-sm font-medium text-gray-700"> <div className="flex justify-between items-center mb-2">
Role & Module Assignments <label className="text-sm font-medium text-gray-700">
</label> Role & Module Assignments
<button </label>
type="button" <button
onClick={() => append({ role_id: "", module_ids: [] })} type="button"
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1" onClick={() => append({ role_id: "", module_ids: [] })}
> className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
<Plus className="w-4 h-4" /> Add Assignment >
</button> <Plus className="w-4 h-4" /> Add Assignment
</div> </button>
</div>
{(errors.role_module_assignments as any)?.message && ( {(errors.role_module_assignments as any)?.message && (
<p className="text-sm text-red-500 mb-2"> <p className="text-sm text-red-500 mb-2">
{(errors.role_module_assignments as any).message} {(errors.role_module_assignments as any).message}
</p> </p>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{fields.map((field, index) => { {fields.map((field, index) => {
const roleIdValue = watch( const roleIdValue = watch(
`role_module_assignments.${index}.role_id`, `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) => {
const opt = initialRoleOptions.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),
); );
const moduleIdsValue =
watch(`role_module_assignments.${index}.module_ids`) || [];
return ( // Extract specific label if available from initial options
<div const getRoleLabel = (val: string) => {
key={field.id} const opt = initialRoleOptions.find((o) => o.value === val);
className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative" return opt ? opt.label : undefined;
> };
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect const initialSelectedModules = moduleIdsValue
label="Select Role" .map((val) => {
required const opt = initialModuleOptions.find(
placeholder="Select Role" (o) => o.value === val,
value={roleIdValue || ""} );
onValueChange={(value) => return opt
setValue( ? { value: opt.value, label: opt.label }
`role_module_assignments.${index}.role_id`, : null;
value, })
{ shouldValidate: true }, .filter((opt): opt is { value: string; label: string } =>
) Boolean(opt),
} );
onLoadOptions={loadRoles}
initialOption={ return (
roleIdValue <div
? { key={field.id}
value: roleIdValue, className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative"
label: >
getRoleLabel(roleIdValue) || roleIdValue, <div className="flex-1 grid grid-cols-2 gap-3">
} <PaginatedSelect
: undefined label="Select Role"
} required
error={ placeholder="Select Role"
errors.role_module_assignments?.[index]?.role_id value={roleIdValue || ""}
?.message onValueChange={(value) =>
} setValue(
/> `role_module_assignments.${index}.role_id`,
<MultiselectPaginatedSelect value,
label="Select Modules" { shouldValidate: true },
required )
placeholder="Select Modules" }
value={moduleIdsValue} onLoadOptions={loadRoles}
onValueChange={(value) => initialOption={
setValue( roleIdValue
`role_module_assignments.${index}.module_ids`, ? {
value, value: roleIdValue,
{ shouldValidate: true }, label:
) getRoleLabel(roleIdValue) || roleIdValue,
} }
onLoadOptions={loadModules} : undefined
initialOptions={initialSelectedModules} }
error={getModuleError(index)} error={
/> errors.role_module_assignments?.[index]?.role_id
?.message
}
/>
<MultiselectPaginatedSelect
label="Select Modules"
required
placeholder="Select Modules"
value={moduleIdsValue}
onValueChange={(value) =>
setValue(
`role_module_assignments.${index}.module_ids`,
value,
{ shouldValidate: true },
)
}
onLoadOptions={loadModules}
initialOptions={initialSelectedModules}
error={getModuleError(index)}
/>
</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>
{fields.length > 1 && ( );
<button })}
type="button" </div>
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> )}
</div> </div>
)} )}
</form> </form>

View File

@ -136,6 +136,21 @@ export const ViewUserModal = ({
</StatusBadge> </StatusBadge>
</div> </div>
</div> </div>
{user.status === "suspended" && user.preferences?.security?.suspended_reason && (
<div className="md:col-span-2 p-3 bg-[rgba(239,68,68,0.05)] border border-[rgba(239,68,68,0.2)] rounded-lg">
<label className="text-xs font-medium text-[#ef4444] mb-1 block">
Suspension Reason
</label>
<p className="text-sm text-[#0e1b2a] font-medium">
{user.preferences.security.suspended_reason}
</p>
{user.preferences.security.suspended_at && (
<p className="text-xs text-[#6b7280] mt-1">
Suspended on: {formatDate(user.preferences.security.suspended_at)}
</p>
)}
</div>
)}
<div> <div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block"> <label className="text-xs font-medium text-[#6b7280] mb-1 block">
Roles Roles

View File

@ -289,7 +289,7 @@ export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(
status: "active" | "suspended" | "deleted"; status: "active" | "suspended" | "deleted";
auth_provider?: string; auth_provider?: string;
tenant_id: string; tenant_id: string;
role_module_combinations: { role_module_combinations?: {
role_id: string; role_id: string;
module_id?: string | null; module_id?: string | null;
}[]; }[];

View File

@ -6,6 +6,7 @@ export interface RoleModuleCombination {
export interface UserRoleModuleCombination { export interface UserRoleModuleCombination {
role_id: string; role_id: string;
role_name: string; role_name: string;
role_code?: string;
module_id: string | null; module_id: string | null;
module_name: string | null; module_name: string | null;
} }
@ -26,10 +27,12 @@ export interface User {
role?: { role?: {
id: string; id: string;
name: string; name: string;
code?: string;
}; };
roles?: { roles?: {
id: string; id: string;
name: string; name: string;
code?: string;
}[]; }[];
department_id?: string; department_id?: string;
designation_id?: string; designation_id?: string;
@ -48,6 +51,7 @@ export interface User {
role_module_combinations?: UserRoleModuleCombination[]; role_module_combinations?: UserRoleModuleCombination[];
category?: 'tenant_user' | 'supplier_user'; category?: 'tenant_user' | 'supplier_user';
supplier_id?: string | null; supplier_id?: string | null;
preferences?: any;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }