feat: implement audit resource type management and optimize image loading with global caching

This commit is contained in:
Yashwin 2026-04-08 10:39:32 +05:30
parent 421aaa1b87
commit 6a798b2cf4
8 changed files with 734 additions and 61 deletions

View File

@ -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' },
];

View File

@ -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 {
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 fetchPromise = (async () => {
let url: string;
if (fileId) {
url = await fileService.getPreview(fileId);
} else {
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

View 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;

View File

@ -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"

View File

@ -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"

View File

@ -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} />,
},
];

View File

@ -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);

View File

@ -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 {