feat: add tenant admin support to EditUserModal and dynamic form validation
This commit is contained in:
parent
04003a2332
commit
f7eb25f874
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}[];
|
}[];
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user