Qassure-frontend/src/components/shared/EditRoleModal.tsx

631 lines
21 KiB
TypeScript

import { useEffect, useState, useRef, useMemo } from "react";
import type { ReactElement } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2, ChevronDown, ChevronRight } from "lucide-react";
import {
Modal,
FormField,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
import type { Role, UpdateRoleRequest } from "@/types/role";
import { useAppSelector } from "@/hooks/redux-hooks";
// Utility function to generate code from name
const generateCodeFromName = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s]/g, "") // Remove special characters except spaces
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/_+/g, "_") // Replace multiple underscores with single underscore
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
};
// All available resources
const ALL_RESOURCES = [
"users",
// 'tenants',
"roles",
"permissions",
// 'user_roles',
// 'user_tenants',
// 'sessions',
// 'api_keys',
// 'api_key_permissions',
"projects",
"document",
"audit",
"security",
"workflow",
"training",
"capa",
"supplier",
"reports",
"notifications",
"files",
"settings",
// 'modules',
"audit_logs",
// 'event_logs',
// 'health_history',
"qms_connections",
"qms_sync_jobs",
"qms_sync_conflicts",
"qms_entity_mappings",
"ai",
"qms",
];
// All available actions
const ALL_ACTIONS = ["create", "read", "update", "delete"];
// Validation schema
const editRoleSchema = z.object({
name: z.string().min(1, "Role name is required"),
code: z
.string()
.min(1, "Code is required")
.regex(
/^[a-z]+(_[a-z]+)*$/,
"Code must be lowercase and use '_' for separation (e.g. abc_def)",
),
description: z.string().min(1, "Description is required"),
permissions: z
.array(
z.object({
resource: z.string(),
action: z.string(),
}),
)
.optional()
.nullable(),
});
type EditRoleFormData = z.infer<typeof editRoleSchema>;
interface EditRoleModalProps {
isOpen: boolean;
onClose: () => void;
roleId: string | null;
onLoadRole: (id: string) => Promise<Role>;
onSubmit: (id: string, data: UpdateRoleRequest) => Promise<void>;
isLoading?: boolean;
defaultTenantId?: string; // If provided, automatically include tenant_id in request body
}
export const EditRoleModal = ({
isOpen,
onClose,
roleId,
onLoadRole,
onSubmit,
isLoading = false,
defaultTenantId,
}: EditRoleModalProps): ReactElement | null => {
const [isLoadingRole, setIsLoadingRole] = useState<boolean>(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadedRoleIdRef = useRef<string | null>(null);
const permissions = useAppSelector((state) => state.auth.permissions);
const roles = useAppSelector((state) => state.auth.roles);
const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId);
const isSuperAdmin = roles.includes("super_admin");
const [selectedPermissions, setSelectedPermissions] = useState<
Array<{ resource: string; action: string }>
>([]);
const [expandedResources, setExpandedResources] = useState<Set<string>>(
new Set(),
);
const {
register,
handleSubmit,
setValue,
watch,
reset,
setError,
clearErrors,
formState: { errors },
} = useForm<EditRoleFormData>({
resolver: zodResolver(editRoleSchema),
defaultValues: {
permissions: [],
},
});
const nameValue = watch("name");
// Auto-generate code from name
useEffect(() => {
if (nameValue) {
const generatedCode = generateCodeFromName(nameValue);
setValue("code", generatedCode, { shouldValidate: true });
}
}, [nameValue, setValue]);
// Build available resources and actions based on user permissions
const availableResourcesAndActions = useMemo(() => {
const resourceMap = new Map<string, Set<string>>();
permissions.forEach((perm) => {
const { resource, action } = perm;
if (resource === "*") {
// If resource is *, show all resources
ALL_RESOURCES.forEach((res) => {
if (!resourceMap.has(res)) {
resourceMap.set(res, new Set());
}
const actions = resourceMap.get(res)!;
if (action === "*") {
// If action is also *, add all actions
ALL_ACTIONS.forEach((act) => actions.add(act));
} else {
actions.add(action);
}
});
} else {
// Specific resource
if (!resourceMap.has(resource)) {
resourceMap.set(resource, new Set());
}
const actions = resourceMap.get(resource)!;
if (action === "*") {
// If action is *, add all actions for this resource
ALL_ACTIONS.forEach((act) => actions.add(act));
} else {
actions.add(action);
}
}
});
return resourceMap;
}, [permissions]);
// Check if a resource has any selected actions
const hasSelectedActions = (
resource: string,
actions: Set<string>,
): boolean => {
return Array.from(actions).some((action) => {
return selectedPermissions.some((p) => {
// Check for exact match
if (p.resource === resource && p.action === action) return true;
// Check for wildcard resource with exact action
if (p.resource === "*" && p.action === action) return true;
// Check for exact resource with wildcard action
if (p.resource === resource && p.action === "*") return true;
// Check for wildcard resource with wildcard action
if (p.resource === "*" && p.action === "*") return true;
return false;
});
});
};
// Toggle resource expansion
const toggleResource = (resource: string) => {
setExpandedResources((prev) => {
const newSet = new Set(prev);
if (newSet.has(resource)) {
newSet.delete(resource);
} else {
newSet.add(resource);
}
return newSet;
});
};
// Handle permission checkbox change
const handlePermissionChange = (
resource: string,
action: string,
checked: boolean,
) => {
setSelectedPermissions((prev) => {
const newPerms = [...prev];
if (checked) {
// Add permission if not already exists
if (
!newPerms.some((p) => p.resource === resource && p.action === action)
) {
newPerms.push({ resource, action });
}
} else {
// Remove permission
return newPerms.filter(
(p) => !(p.resource === resource && p.action === action),
);
}
return newPerms;
});
};
// Update form value when permissions change
useEffect(() => {
setValue(
"permissions",
selectedPermissions.length > 0 ? selectedPermissions : [],
);
}, [selectedPermissions, setValue]);
// Expand resources that have selected permissions when role is loaded
useEffect(() => {
if (
selectedPermissions.length > 0 &&
availableResourcesAndActions.size > 0
) {
const resourcesWithPermissions = new Set<string>();
selectedPermissions.forEach((perm) => {
if (perm.resource === "*") {
// If wildcard resource, expand all available resources
availableResourcesAndActions.forEach((_, resource) => {
resourcesWithPermissions.add(resource);
});
} else if (availableResourcesAndActions.has(perm.resource)) {
// Only expand if resource exists in available resources
resourcesWithPermissions.add(perm.resource);
}
});
// Only update if we have resources to expand and they're not already expanded
if (resourcesWithPermissions.size > 0) {
setExpandedResources((prev) => {
const newSet = new Set(prev);
resourcesWithPermissions.forEach((resource) => {
if (!newSet.has(resource)) {
newSet.add(resource);
}
});
return newSet;
});
}
}
}, [selectedPermissions, availableResourcesAndActions]);
// Load role data when modal opens - only load once per roleId
useEffect(() => {
if (isOpen && roleId) {
// Only load if this is a new roleId or modal was closed and reopened
if (loadedRoleIdRef.current !== roleId) {
const loadRole = async (): Promise<void> => {
try {
setIsLoadingRole(true);
setLoadError(null);
clearErrors();
const role = await onLoadRole(roleId);
loadedRoleIdRef.current = roleId;
// Set permissions (always set, even if empty array)
const rolePermissions = role.permissions || [];
setSelectedPermissions(rolePermissions);
setValue("permissions", rolePermissions);
// Expand resources that have selected permissions
// This will be handled by useEffect after availableResourcesAndActions is computed
reset({
name: role.name,
code: role.code,
description: role.description || "",
permissions: rolePermissions,
});
} catch (err: any) {
setLoadError(
err?.response?.data?.error?.message ||
"Failed to load role details",
);
} finally {
setIsLoadingRole(false);
}
};
loadRole();
}
} else if (!isOpen) {
// Only reset when modal is closed
loadedRoleIdRef.current = null;
setSelectedPermissions([]);
reset({
name: "",
code: "",
description: "",
permissions: [],
});
setLoadError(null);
clearErrors();
}
}, [
isOpen,
roleId,
onLoadRole,
reset,
clearErrors,
setValue,
isSuperAdmin,
defaultTenantId,
tenantIdFromAuth,
]);
const handleFormSubmit = async (data: EditRoleFormData): Promise<void> => {
if (!roleId) return;
clearErrors();
try {
const submitData = {
...data,
// For super_admin, always include tenant_id if defaultTenantId is provided
tenant_id: isSuperAdmin
? defaultTenantId || undefined
: defaultTenantId || undefined,
permissions:
selectedPermissions.length > 0 ? selectedPermissions : undefined,
};
await onSubmit(roleId, submitData as UpdateRoleRequest);
// Only reset form on success - this will be handled by parent closing modal
} catch (error: any) {
// Don't reset form on error - keep the form data and show errors
// 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 === "name" ||
detail.path === "code" ||
detail.path === "description" ||
detail.path === "permissions"
) {
setError(detail.path as keyof EditRoleFormData, {
type: "server",
message: detail.message,
});
}
},
);
} else {
// Handle general errors
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 update role. Please try again.";
setError("root", {
type: "server",
message:
typeof errorMessage === "string"
? errorMessage
: "Failed to update role. Please try again.",
});
}
// Re-throw error to prevent form from thinking it succeeded
throw error;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Edit Role"
description="Update role by setting permissions and role type."
maxWidth="lg"
footer={
<>
<SecondaryButton
type="button"
onClick={onClose}
disabled={isLoading || isLoadingRole}
className="px-4 py-2.5 text-sm"
>
Cancel
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleFormSubmit)}
disabled={isLoading || isLoadingRole}
size="default"
className="px-4 py-2.5 text-sm"
>
{isLoading ? "Updating..." : "Update Role"}
</PrimaryButton>
</>
}
>
{isLoadingRole && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
)}
{/* General Error Display - Always visible */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4 mx-5 mt-5">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{loadError && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4 mx-5">
<p className="text-sm text-[#ef4444]">{loadError}</p>
</div>
)}
{!isLoadingRole && (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="p-5 flex flex-col gap-0"
>
{/* Role Name and Role Code Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField
label="Role Name"
required
placeholder="Enter Text Here"
error={errors.name?.message}
{...register("name")}
/>
<FormField
label="Role Code"
required
placeholder="Auto-generated from name"
error={errors.code?.message}
{...register("code")}
disabled
className="bg-[#f3f4f6] cursor-not-allowed"
/>
</div>
{/* Description */}
<FormField
label="Description"
required
placeholder="Enter Text Here"
error={errors.description?.message}
{...register("description")}
/>
{/* Permissions Section */}
<div className="pb-4">
<label className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a] mb-3">
<span>Permissions</span>
</label>
{errors.permissions && (
<p className="text-sm text-[#ef4444] mb-2">
{errors.permissions.message}
</p>
)}
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4">
{Array.from(availableResourcesAndActions.entries()).length ===
0 ? (
<p className="text-sm text-[#6b7280]">
No permissions available
</p>
) : (
<div className="space-y-2">
{Array.from(availableResourcesAndActions.entries()).map(
([resource, actions]) => {
const isExpanded = expandedResources.has(resource);
const hasSelected = hasSelectedActions(resource, actions);
return (
<div
key={resource}
className={`border border-[rgba(0,0,0,0.08)] rounded-md overflow-hidden transition-colors ${
hasSelected
? "bg-[rgba(17,40,104,0.05)]"
: "bg-white"
}`}
>
<button
type="button"
onClick={() => toggleResource(resource)}
className="w-full flex items-center justify-between p-3 hover:bg-[rgba(0,0,0,0.02)] transition-colors"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-[#0e1b2a]" />
) : (
<ChevronRight className="w-4 h-4 text-[#0e1b2a]" />
)}
<span
className={`font-medium text-sm capitalize ${
hasSelected
? "text-[#112868]"
: "text-[#0e1b2a]"
}`}
>
{resource.replace(/_/g, " ")}
</span>
{hasSelected && (
<span className="text-xs text-[#112868] bg-[rgba(17,40,104,0.1)] px-2 py-0.5 rounded">
Selected
</span>
)}
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-2 border-t border-[rgba(0,0,0,0.05)]">
<div className="flex flex-wrap gap-4">
{Array.from(actions).map((action) => {
const isChecked = selectedPermissions.some(
(p) => {
// Check for exact match
if (
p.resource === resource &&
p.action === action
) {
return true;
}
// Check for wildcard resource with exact action
if (
p.resource === "*" &&
p.action === action
) {
return true;
}
// Check for exact resource with wildcard action
if (
p.resource === resource &&
p.action === "*"
) {
return true;
}
// Check for wildcard resource with wildcard action
if (
p.resource === "*" &&
p.action === "*"
) {
return true;
}
return false;
},
);
return (
<label
key={`${resource}-${action}`}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) =>
handlePermissionChange(
resource,
action,
e.target.checked,
)
}
className="w-4 h-4 text-[#112868] border-[rgba(0,0,0,0.2)] rounded focus:ring-[#112868]/20"
/>
<span className="text-sm text-[#0e1b2a] capitalize">
{action}
</span>
</label>
);
})}
</div>
</div>
)}
</div>
);
},
)}
</div>
)}
</div>
</div>
</form>
)}
</Modal>
);
};