feat: implement audit resource type management and optimize image loading with global caching
This commit is contained in:
parent
421aaa1b87
commit
6a798b2cf4
@ -60,6 +60,7 @@ const superAdminPlatformMenu: MenuItem[] = [
|
||||
const superAdminSystemMenu: MenuItem[] = [
|
||||
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
||||
{ icon: Shield, label: "Audit Resources", path: "/audit-resource-types" },
|
||||
// { icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
@ -8,6 +8,10 @@ import { fileService } from "@/services/file-service";
|
||||
import apiClient from "@/services/api-client";
|
||||
import { Loader2, ImageIcon } from "lucide-react";
|
||||
|
||||
// Global cache to persist blob URLs between component remounts (prevents re-fetching on every page click)
|
||||
const BLOB_CACHE = new Map<string, string>();
|
||||
const PENDING_REQUESTS = new Map<string, Promise<string>>();
|
||||
|
||||
interface AuthenticatedImageProps extends Omit<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
"src"
|
||||
@ -41,28 +45,56 @@ export const AuthenticatedImage = ({
|
||||
const isBackendUrl =
|
||||
src && src.includes(`${baseUrl}/files/`) && src.includes("/preview");
|
||||
|
||||
// If we have a fileId or a backend URL, fetch it via authenticated request
|
||||
const cacheKey = fileId || src;
|
||||
if (!cacheKey) return;
|
||||
|
||||
// 1. Check if already in cache
|
||||
if (BLOB_CACHE.has(cacheKey)) {
|
||||
setBlobUrl(BLOB_CACHE.get(cacheKey)!);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
|
||||
if (fileId || isBackendUrl) {
|
||||
let isMounted = true;
|
||||
const fetchImage = async () => {
|
||||
// 3. Check if there's already a pending request for this same image
|
||||
if (PENDING_REQUESTS.has(cacheKey)) {
|
||||
try {
|
||||
const url = await PENDING_REQUESTS.get(cacheKey)!;
|
||||
if (isMounted) setBlobUrl(url);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (isMounted) setError(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const fetchPromise = (async () => {
|
||||
let url: string;
|
||||
if (fileId) {
|
||||
url = await fileService.getPreview(fileId);
|
||||
} else if (src) {
|
||||
const response = await apiClient.get(src, { responseType: "blob" });
|
||||
url = URL.createObjectURL(response.data);
|
||||
} else {
|
||||
return;
|
||||
const response = await apiClient.get(src!, { responseType: "blob" });
|
||||
url = URL.createObjectURL(response.data);
|
||||
}
|
||||
BLOB_CACHE.set(cacheKey, url);
|
||||
return url;
|
||||
})();
|
||||
|
||||
PENDING_REQUESTS.set(cacheKey, fetchPromise);
|
||||
const url = await fetchPromise;
|
||||
PENDING_REQUESTS.delete(cacheKey);
|
||||
|
||||
if (isMounted) {
|
||||
setBlobUrl(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch authenticated image:", err);
|
||||
PENDING_REQUESTS.delete(cacheKey);
|
||||
if (isMounted) {
|
||||
setError(true);
|
||||
}
|
||||
@ -77,9 +109,8 @@ export const AuthenticatedImage = ({
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (blobUrl && blobUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
// NOTE: We no longer revoke the URL here because it's stored in the global BLOB_CACHE
|
||||
// for reuse when the component remounts on other pages.
|
||||
};
|
||||
} else if (src) {
|
||||
// For other external URLs, use them directly
|
||||
|
||||
471
src/pages/superadmin/AuditLogResourceTypes.tsx
Normal file
471
src/pages/superadmin/AuditLogResourceTypes.tsx
Normal file
@ -0,0 +1,471 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
DataTable,
|
||||
StatusBadge,
|
||||
Modal,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormField,
|
||||
FormSelect,
|
||||
FilterDropdown,
|
||||
Pagination,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Pencil, Trash2, Settings, Check, X, Building2, Search } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const resourceTypeSchema = z.object({
|
||||
name: z.string().min(1, 'Resource name is required').max(100, 'Name is too long'),
|
||||
type: z.enum(['PLATFORM', 'MODULE']),
|
||||
module_id: z.string().optional().nullable(),
|
||||
audit_log_tracking: z.boolean(),
|
||||
}).refine((data) => {
|
||||
if (data.type === 'MODULE' && !data.module_id) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Module is required when type is MODULE',
|
||||
path: ['module_id'],
|
||||
});
|
||||
|
||||
type ResourceTypeFormData = z.infer<typeof resourceTypeSchema>;
|
||||
|
||||
interface ResourceType {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'PLATFORM' | 'MODULE';
|
||||
module_id: string | null;
|
||||
audit_log_tracking: boolean;
|
||||
module?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AuditLogResourceTypes = (): ReactElement => {
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([]);
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [selectedRT, setSelectedRT] = useState<ResourceType | null>(null);
|
||||
|
||||
// Pagination & Filtering State
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(10);
|
||||
const [totalItems, setTotalItems] = useState<number>(0);
|
||||
const [totalPages, setTotalPages] = useState<number>(1);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [moduleIdFilter, setModuleIdFilter] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ResourceTypeFormData>({
|
||||
resolver: zodResolver(resourceTypeSchema) as any,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
type: 'PLATFORM',
|
||||
module_id: '',
|
||||
audit_log_tracking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const typeValue = watch('type');
|
||||
const moduleIdValue = watch('module_id');
|
||||
const trackingValue = watch('audit_log_tracking');
|
||||
|
||||
const fetchResourceTypes = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await auditLogService.getAllResourceTypes(currentPage, limit, {
|
||||
search: debouncedSearch,
|
||||
type: typeFilter,
|
||||
module_id: moduleIdFilter
|
||||
});
|
||||
if (response.success) {
|
||||
setResourceTypes(response.data);
|
||||
setTotalItems(response.pagination.total);
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to load resource types');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, limit, debouncedSearch, typeFilter, moduleIdFilter]);
|
||||
|
||||
const fetchModules = async () => {
|
||||
try {
|
||||
const response = await moduleService.getAll(1, 100);
|
||||
if (response.success) {
|
||||
setModules(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResourceTypes();
|
||||
}, [fetchResourceTypes]);
|
||||
|
||||
// Debouncing for Search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setCurrentPage(1); // Reset to page 1 on new search
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const handleOpenModal = (rt?: ResourceType) => {
|
||||
if (rt) {
|
||||
setIsEditing(true);
|
||||
setSelectedRT(rt);
|
||||
reset({
|
||||
name: rt.name,
|
||||
type: rt.type,
|
||||
module_id: rt.module_id || '',
|
||||
audit_log_tracking: rt.audit_log_tracking,
|
||||
});
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
setSelectedRT(null);
|
||||
reset({
|
||||
name: '',
|
||||
type: 'PLATFORM',
|
||||
module_id: '',
|
||||
audit_log_tracking: true,
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const onFormSubmit = async (data: ResourceTypeFormData) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const payload = {
|
||||
...data,
|
||||
module_id: data.type === 'MODULE' ? data.module_id : null,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEditing && selectedRT) {
|
||||
response = await auditLogService.updateResourceType(selectedRT.id, payload);
|
||||
} else {
|
||||
response = await auditLogService.createResourceType(payload);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Resource type ${isEditing ? 'updated' : 'created'} successfully`);
|
||||
setIsModalOpen(false);
|
||||
fetchResourceTypes();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Operation failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this resource type?')) return;
|
||||
try {
|
||||
const response = await auditLogService.deleteResourceType(id);
|
||||
if (response.success) {
|
||||
toast.success('Resource type deleted successfully');
|
||||
fetchResourceTypes();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete resource type');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<ResourceType>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (rt) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-[#475569]" />
|
||||
<span className="text-sm font-medium text-[#0f1724]">{rt.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (rt) => (
|
||||
<StatusBadge variant={rt.type === 'PLATFORM' ? 'info' : 'process'}>
|
||||
{rt.type}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'module',
|
||||
label: 'Associated Module',
|
||||
render: (rt) => rt.type === 'MODULE' && rt.module_id ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-[#475569]">
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
<span>{rt.module?.name || 'Loading...'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-[#9aa6b2]">N/A (Platform)</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'audit_log_tracking',
|
||||
label: 'Tracking',
|
||||
render: (rt) => (
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${rt.audit_log_tracking ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>
|
||||
{rt.audit_log_tracking ? <Check className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
|
||||
<span>{rt.audit_log_tracking ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (rt) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenModal(rt)}
|
||||
className="p-1.5 text-[#475569] hover:text-[#112868] hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(rt.id)}
|
||||
className="p-1.5 text-[#ef4444] hover:bg-red-50 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Resource Types"
|
||||
pageHeader={{
|
||||
title: 'Audit Resource Management',
|
||||
description: 'Manage platform and module-specific resource types for audit log tracking.',
|
||||
action: (
|
||||
<PrimaryButton
|
||||
onClick={() => handleOpenModal()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Resource Type</span>
|
||||
</PrimaryButton>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Filter Row */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 bg-white p-4 border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
||||
{/* Search Input */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868] transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<FilterDropdown
|
||||
label="Type"
|
||||
options={[
|
||||
{ value: 'PLATFORM', label: 'Platform' },
|
||||
{ value: 'MODULE', label: 'Module' },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(val) => {
|
||||
setTypeFilter(val as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
|
||||
{/* Module Filter */}
|
||||
<FilterDropdown
|
||||
label="Module"
|
||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||
value={moduleIdFilter}
|
||||
onChange={(val) => {
|
||||
setModuleIdFilter(val as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All Modules"
|
||||
/>
|
||||
|
||||
{(debouncedSearch || typeFilter || moduleIdFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
setTypeFilter(null);
|
||||
setModuleIdFilter(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-xs text-[#ef4444] hover:underline font-medium ml-1"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#6b7280] font-medium">
|
||||
Total Results: <span className="text-[#0f1724]">{totalItems}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={resourceTypes}
|
||||
keyExtractor={(rt) => rt.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No resource types configured yet."
|
||||
/>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={(newLimit) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={isEditing ? 'Edit Resource Type' : 'Create Resource Type'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<div className="flex gap-3 w-full">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(onFormSubmit as any)}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : (isEditing ? 'Save Changes' : 'Create Type')}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit as any)} className="p-6 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="Resource Name"
|
||||
required
|
||||
placeholder="e.g. MasterFile, UserProfile"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label className="text-[13px] font-medium text-[#0e1b2a]">
|
||||
Type <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<div className="flex gap-6 mt-1">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
value="PLATFORM"
|
||||
checked={typeValue === 'PLATFORM'}
|
||||
onChange={() => setValue('type', 'PLATFORM', { shouldValidate: true })}
|
||||
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-[rgba(0,0,0,0.1)]"
|
||||
/>
|
||||
<span className="text-sm text-[#0f1724] group-hover:text-[#112868] transition-colors">Platform</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
value="MODULE"
|
||||
checked={typeValue === 'MODULE'}
|
||||
onChange={() => setValue('type', 'MODULE', { shouldValidate: true })}
|
||||
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-[rgba(0,0,0,0.1)]"
|
||||
/>
|
||||
<span className="text-sm text-[#0f1724] group-hover:text-[#112868] transition-colors">Module</span>
|
||||
</label>
|
||||
</div>
|
||||
{errors.type && <p className="text-sm text-[#ef4444] mt-1">{errors.type.message}</p>}
|
||||
</div>
|
||||
|
||||
{typeValue === 'MODULE' && (
|
||||
<FormSelect
|
||||
label="Select Module"
|
||||
required
|
||||
placeholder="Choose a module..."
|
||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||
value={moduleIdValue || ''}
|
||||
onValueChange={(val) => setValue('module_id', val, { shouldValidate: true })}
|
||||
error={errors.module_id?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50/50 rounded-xl border border-[rgba(0,0,0,0.06)] mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="tracking"
|
||||
checked={trackingValue}
|
||||
onChange={(e) => setValue('audit_log_tracking', e.target.checked)}
|
||||
className="w-4.5 h-4.5 text-[#112868] rounded border-[rgba(0,0,0,0.1)] focus:ring-[#112868] cursor-pointer"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="tracking" className="text-[13px] font-semibold text-[#0e1b2a] cursor-pointer">
|
||||
Enable Audit Log Tracking
|
||||
</label>
|
||||
<p className="text-[11px] text-[#6b7280]">When enabled, all security actions for this resource will be logged.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogResourceTypes;
|
||||
@ -9,7 +9,7 @@ import {
|
||||
StatusBadge,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Download, ArrowUpDown } from 'lucide-react';
|
||||
import { Download, ArrowUpDown, Search } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
@ -60,6 +60,7 @@ const getStatusColor = (status: number | null): string => {
|
||||
const AuditLogs = (): ReactElement => {
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -88,6 +89,8 @@ const AuditLogs = (): ReactElement => {
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
||||
|
||||
// View modal
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
@ -105,7 +108,24 @@ const AuditLogs = (): ReactElement => {
|
||||
console.error('Failed to load tenants:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchResourceTypes = async () => {
|
||||
try {
|
||||
const response = await auditLogService.getResourceTypesDropdown();
|
||||
if (response.success) {
|
||||
const options = response.data.map((rt: any) => ({
|
||||
value: rt.value,
|
||||
label: rt.label
|
||||
}));
|
||||
setResourceTypes(options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load resource types:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTenants();
|
||||
fetchResourceTypes();
|
||||
}, []);
|
||||
|
||||
const fetchAuditLogs = async (): Promise<void> => {
|
||||
@ -122,6 +142,7 @@ const AuditLogs = (): ReactElement => {
|
||||
resource_type: resourceTypeFilter,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
search: debouncedSearch || null,
|
||||
},
|
||||
orderBy
|
||||
);
|
||||
@ -138,10 +159,19 @@ const AuditLogs = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
// Debouncing for Search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Fetch audit logs on mount and when pagination/filters change
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy]);
|
||||
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, debouncedSearch]);
|
||||
|
||||
// Handle Export
|
||||
const handleExport = async (): Promise<void> => {
|
||||
@ -205,6 +235,15 @@ const AuditLogs = (): ReactElement => {
|
||||
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'module',
|
||||
label: 'Module',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#475569]">
|
||||
{log.module?.name || 'Platform'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
@ -285,6 +324,10 @@ const AuditLogs = (): ReactElement => {
|
||||
<span className="text-[#9aa6b2]">Timestamp:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Module:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{log.module?.name || 'Platform'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">User:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
@ -314,8 +357,20 @@ const AuditLogs = (): ReactElement => {
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Global Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs & metadata..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868] transition-all bg-gray-50/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tenant Selector */}
|
||||
<FilterDropdown
|
||||
label="Tenant"
|
||||
@ -348,13 +403,8 @@ const AuditLogs = (): ReactElement => {
|
||||
|
||||
{/* Resource Filter */}
|
||||
<FilterDropdown
|
||||
label="Resource"
|
||||
options={[
|
||||
{ value: 'document', label: 'Document' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'role', label: 'Role' },
|
||||
]}
|
||||
label="Resource Type"
|
||||
options={resourceTypes}
|
||||
value={resourceTypeFilter}
|
||||
onChange={(value) => {
|
||||
setResourceTypeFilter(value as string | null);
|
||||
@ -436,7 +486,7 @@ const AuditLogs = (): ReactElement => {
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
|
||||
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter) && (
|
||||
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || search) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
@ -444,6 +494,7 @@ const AuditLogs = (): ReactElement => {
|
||||
setTenantFilter(null);
|
||||
setActionFilter(null);
|
||||
setResourceTypeFilter(null);
|
||||
setSearch('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-xs text-[#ef4444] hover:underline ml-auto"
|
||||
|
||||
@ -9,8 +9,9 @@ import {
|
||||
StatusBadge,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Download, ArrowUpDown } from 'lucide-react';
|
||||
import { Download, ArrowUpDown, Search } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
|
||||
@ -62,6 +63,8 @@ const AuditLogs = (): ReactElement => {
|
||||
const isTenantAdmin = roles?.includes('tenant_admin');
|
||||
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
||||
const [modules, setModules] = useState<{ value: string; label: string }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -86,14 +89,47 @@ const AuditLogs = (): ReactElement => {
|
||||
const [methodFilter, setMethodFilter] = useState<string | null>(null);
|
||||
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
||||
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
|
||||
const [moduleIdFilter, setModuleIdFilter] = useState<string | null>(null); // New module_id filter
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
||||
|
||||
// View modal
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
|
||||
|
||||
const fetchResourceTypes = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await auditLogService.getResourceTypesDropdown(tenantId as string | undefined);
|
||||
if (response.success) {
|
||||
const options = response.data.map((rt: any) => ({
|
||||
value: rt.value,
|
||||
label: rt.label
|
||||
}));
|
||||
setResourceTypes(options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load resource types', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModules = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await moduleService.getMyModules(tenantId as string | undefined);
|
||||
if (response.success) {
|
||||
const options = response.data.map((m: any) => ({
|
||||
value: m.id,
|
||||
label: m.name
|
||||
}));
|
||||
setModules(options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAuditLogs = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -108,14 +144,16 @@ const AuditLogs = (): ReactElement => {
|
||||
method: methodFilter,
|
||||
action: actionFilter,
|
||||
resource_type: resourceTypeFilter,
|
||||
module_id: moduleIdFilter, // Pass module_id filter
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
tenant_id: tenantId
|
||||
tenant_id: tenantId as string | null,
|
||||
search: debouncedSearch || null
|
||||
},
|
||||
orderBy
|
||||
);
|
||||
} else {
|
||||
response = await auditLogService.getMyLogs(currentPage, limit);
|
||||
response = await auditLogService.getMyLogs(currentPage, limit, moduleIdFilter, debouncedSearch || null);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
@ -131,10 +169,25 @@ const AuditLogs = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch resource types and modules on mount
|
||||
useEffect(() => {
|
||||
fetchResourceTypes();
|
||||
fetchModules();
|
||||
}, [tenantId]);
|
||||
|
||||
// Debouncing for Search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Fetch audit logs on mount and when pagination/filters change
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, tenantId]);
|
||||
}, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, moduleIdFilter, startDate, endDate, orderBy, tenantId, debouncedSearch]);
|
||||
|
||||
// View audit log handler
|
||||
const handleViewAuditLog = (auditLogId: string): void => {
|
||||
@ -193,6 +246,15 @@ const AuditLogs = (): ReactElement => {
|
||||
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'module',
|
||||
label: 'Module',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#475569]">
|
||||
{log.module?.name || 'Platform'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
@ -202,15 +264,15 @@ const AuditLogs = (): ReactElement => {
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
...(isTenantAdmin ? [{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
render: (log) => (
|
||||
render: (log: AuditLog) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'request_method',
|
||||
label: 'Method',
|
||||
@ -281,6 +343,10 @@ const AuditLogs = (): ReactElement => {
|
||||
<span className="text-[#9aa6b2]">Timestamp:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Module:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{log.module?.name || 'Platform'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">User:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
@ -320,8 +386,20 @@ const AuditLogs = (): ReactElement => {
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Global Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs & metadata..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868] transition-all bg-gray-50/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isTenantAdmin && (
|
||||
<>
|
||||
{/* Action Filter */}
|
||||
@ -346,15 +424,8 @@ const AuditLogs = (): ReactElement => {
|
||||
|
||||
{/* Resource Type Filter */}
|
||||
<FilterDropdown
|
||||
label="Resource"
|
||||
options={[
|
||||
{ value: 'document', label: 'Document' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'capa', label: 'CAPA' },
|
||||
{ value: 'workflow', label: 'Workflow' },
|
||||
{ value: 'training', label: 'Training' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
]}
|
||||
label="Resource Type"
|
||||
options={resourceTypes}
|
||||
value={resourceTypeFilter}
|
||||
onChange={(value) => {
|
||||
setResourceTypeFilter(value as string | null);
|
||||
@ -362,26 +433,21 @@ const AuditLogs = (): ReactElement => {
|
||||
}}
|
||||
placeholder="All Resources"
|
||||
/>
|
||||
|
||||
{/* Method Filter */}
|
||||
<FilterDropdown
|
||||
label="Method"
|
||||
options={[
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
]}
|
||||
value={methodFilter}
|
||||
onChange={(value) => {
|
||||
setMethodFilter(value as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All Methods"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Module Filter */}
|
||||
<FilterDropdown
|
||||
label="Module"
|
||||
options={modules}
|
||||
value={moduleIdFilter}
|
||||
onChange={(value) => {
|
||||
setModuleIdFilter(value as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All Modules"
|
||||
/>
|
||||
|
||||
{/* Sort Filter - Always show as it's useful for everyone */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
@ -443,7 +509,7 @@ const AuditLogs = (): ReactElement => {
|
||||
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
|
||||
/>
|
||||
</div>
|
||||
{(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter) && (
|
||||
{(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter || moduleIdFilter || search) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
@ -451,6 +517,8 @@ const AuditLogs = (): ReactElement => {
|
||||
setActionFilter(null);
|
||||
setResourceTypeFilter(null);
|
||||
setMethodFilter(null);
|
||||
setModuleIdFilter(null);
|
||||
setSearch('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-xs text-[#ef4444] hover:underline"
|
||||
|
||||
@ -14,6 +14,7 @@ const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
|
||||
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
|
||||
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||
const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes"));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -80,4 +81,8 @@ export const superAdminRoutes: RouteConfig[] = [
|
||||
path: "/notifications",
|
||||
element: <LazyRoute component={Notifications} />,
|
||||
},
|
||||
{
|
||||
path: "/audit-resource-types",
|
||||
element: <LazyRoute component={AuditLogResourceTypes} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -12,8 +12,10 @@ export const auditLogService = {
|
||||
user_id?: string | null;
|
||||
response_status?: number | null;
|
||||
tenant_id?: string | null;
|
||||
module_id?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
search?: string | null;
|
||||
} = {},
|
||||
orderBy?: string[] | null
|
||||
): Promise<AuditLogsResponse> => {
|
||||
@ -27,8 +29,10 @@ export const auditLogService = {
|
||||
if (filters.user_id) params.append('user_id', filters.user_id);
|
||||
if (filters.response_status) params.append('response_status', String(filters.response_status));
|
||||
if (filters.tenant_id) params.append('tenant_id', filters.tenant_id);
|
||||
if (filters.module_id) params.append('module_id', filters.module_id);
|
||||
if (filters.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
@ -39,9 +43,15 @@ export const auditLogService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMyLogs: async (page: number = 1, limit: number = 50): Promise<AuditLogsResponse> => {
|
||||
getMyLogs: async (page: number = 1, limit: number = 50, module_id?: string | null, search?: string | null): Promise<AuditLogsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (module_id) params.append('module_id', module_id);
|
||||
if (search) params.append('search', search);
|
||||
|
||||
const response = await apiClient.get<AuditLogsResponse>(
|
||||
`/audit-logs/my-logs?page=${page}&limit=${limit}`
|
||||
`/audit-logs/my-logs?${params.toString()}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@ -51,6 +61,40 @@ export const auditLogService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getResourceTypesDropdown: async (tenant_id?: string): Promise<any> => {
|
||||
const params = new URLSearchParams();
|
||||
if (tenant_id) params.append('tenant_id', tenant_id);
|
||||
const response = await apiClient.get(`/audit-log-resource-types/dropdown?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAllResourceTypes: async (page = 1, limit = 50, filters: any = {}): Promise<any> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.type) params.append('type', filters.type);
|
||||
if (filters.module_id) params.append('module_id', filters.module_id);
|
||||
|
||||
const response = await apiClient.get(`/audit-log-resource-types?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createResourceType: async (data: any): Promise<any> => {
|
||||
const response = await apiClient.post('/audit-log-resource-types', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateResourceType: async (id: string, data: any): Promise<any> => {
|
||||
const response = await apiClient.put(`/audit-log-resource-types/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteResourceType: async (id: string): Promise<any> => {
|
||||
const response = await apiClient.delete(`/audit-log-resource-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (params: { tenantId?: string; startDate?: string; endDate?: string } = {}): Promise<any> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.tenantId) searchParams.append('tenantId', params.tenantId);
|
||||
|
||||
@ -17,6 +17,7 @@ export interface AuditLog {
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string | null;
|
||||
module_id: string | null;
|
||||
request_method: string | null;
|
||||
request_path: string | null;
|
||||
request_body: Record<string, unknown> | null;
|
||||
@ -32,6 +33,7 @@ export interface AuditLog {
|
||||
user_email: string | null;
|
||||
user: AuditLogUser | null;
|
||||
tenant: AuditLogTenant | null;
|
||||
module: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user