diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c875552..c1c46a6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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' }, ]; diff --git a/src/components/shared/AuthenticatedImage.tsx b/src/components/shared/AuthenticatedImage.tsx index bf97227..2572455 100644 --- a/src/components/shared/AuthenticatedImage.tsx +++ b/src/components/shared/AuthenticatedImage.tsx @@ -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(); +const PENDING_REQUESTS = new Map>(); + interface AuthenticatedImageProps extends Omit< ImgHTMLAttributes, "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 diff --git a/src/pages/superadmin/AuditLogResourceTypes.tsx b/src/pages/superadmin/AuditLogResourceTypes.tsx new file mode 100644 index 0000000..7497082 --- /dev/null +++ b/src/pages/superadmin/AuditLogResourceTypes.tsx @@ -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; + +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([]); + const [modules, setModules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [selectedRT, setSelectedRT] = useState(null); + + // Pagination & Filtering State + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(10); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState(null); + const [moduleIdFilter, setModuleIdFilter] = useState(null); + + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + 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[] = [ + { + key: 'name', + label: 'Name', + render: (rt) => ( +
+ + {rt.name} +
+ ), + }, + { + key: 'type', + label: 'Type', + render: (rt) => ( + + {rt.type} + + ), + }, + { + key: 'module', + label: 'Associated Module', + render: (rt) => rt.type === 'MODULE' && rt.module_id ? ( +
+ + {rt.module?.name || 'Loading...'} +
+ ) : ( + N/A (Platform) + ), + }, + { + key: 'audit_log_tracking', + label: 'Tracking', + render: (rt) => ( +
+ {rt.audit_log_tracking ? : } + {rt.audit_log_tracking ? 'Enabled' : 'Disabled'} +
+ ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (rt) => ( +
+ + +
+ ), + }, + ]; + + return ( + handleOpenModal()} + className="flex items-center gap-2" + > + + Create Resource Type + + ), + }} + > +
+ {/* Filter Row */} +
+
+ {/* Search Input */} +
+ + 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" + /> +
+ + {/* Type Filter */} + { + setTypeFilter(val as string | null); + setCurrentPage(1); + }} + placeholder="All Types" + /> + + {/* Module Filter */} + ({ value: m.id, label: m.name }))} + value={moduleIdFilter} + onChange={(val) => { + setModuleIdFilter(val as string | null); + setCurrentPage(1); + }} + placeholder="All Modules" + /> + + {(debouncedSearch || typeFilter || moduleIdFilter) && ( + + )} +
+ +
+ Total Results: {totalItems} +
+
+ +
+ rt.id} + isLoading={isLoading} + emptyMessage="No resource types configured yet." + /> + + {totalItems > 0 && ( + { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} +
+
+ + setIsModalOpen(false)} + title={isEditing ? 'Edit Resource Type' : 'Create Resource Type'} + maxWidth="md" + footer={ +
+ setIsModalOpen(false)} + className="flex-1" + disabled={isSubmitting} + > + Cancel + + + {isSubmitting ? 'Saving...' : (isEditing ? 'Save Changes' : 'Create Type')} + +
+ } + > +
+ + +
+ +
+ + +
+ {errors.type &&

{errors.type.message}

} +
+ + {typeValue === 'MODULE' && ( + ({ value: m.id, label: m.name }))} + value={moduleIdValue || ''} + onValueChange={(val) => setValue('module_id', val, { shouldValidate: true })} + error={errors.module_id?.message} + /> + )} + +
+ 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" + /> +
+ +

When enabled, all security actions for this resource will be logged.

+
+
+ +
+
+ ); +}; + +export default AuditLogResourceTypes; diff --git a/src/pages/superadmin/AuditLogs.tsx b/src/pages/superadmin/AuditLogs.tsx index d77b53e..512271d 100644 --- a/src/pages/superadmin/AuditLogs.tsx +++ b/src/pages/superadmin/AuditLogs.tsx @@ -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([]); const [tenants, setTenants] = useState([]); + const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -88,6 +89,8 @@ const AuditLogs = (): ReactElement => { const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [orderBy, setOrderBy] = useState(null); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); // View modal const [viewModalOpen, setViewModalOpen] = useState(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 => { @@ -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 => { @@ -205,6 +235,15 @@ const AuditLogs = (): ReactElement => { {log.resource_type} ), }, + { + key: 'module', + label: 'Module', + render: (log) => ( + + {log.module?.name || 'Platform'} + + ), + }, { key: 'action', label: 'Action', @@ -285,6 +324,10 @@ const AuditLogs = (): ReactElement => { Timestamp:

{formatDate(log.created_at)}

+
+ Module: +

{log.module?.name || 'Platform'}

+
User:

@@ -314,8 +357,20 @@ const AuditLogs = (): ReactElement => { {/* Table Header with Filters */}

- {/* Filters */} + {/* Search and Filters */}
+ {/* Global Search */} +
+ + 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" + /> +
+ {/* Tenant Selector */} { {/* Resource Filter */} { setResourceTypeFilter(value as string | null); @@ -436,7 +486,7 @@ const AuditLogs = (): ReactElement => { icon={} /> - {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter) && ( + {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || search) && (
+
+ Module: +

{log.module?.name || 'Platform'}

+
User:

@@ -320,8 +386,20 @@ const AuditLogs = (): ReactElement => { {/* Table Header with Filters */}

- {/* Filters */} + {/* Search and Filters */}
+ {/* Global Search */} +
+ + 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" + /> +
+ {isTenantAdmin && ( <> {/* Action Filter */} @@ -346,15 +424,8 @@ const AuditLogs = (): ReactElement => { {/* Resource Type Filter */} { setResourceTypeFilter(value as string | null); @@ -362,26 +433,21 @@ const AuditLogs = (): ReactElement => { }} placeholder="All Resources" /> - - {/* Method Filter */} - { - setMethodFilter(value as string | null); - setCurrentPage(1); - }} - placeholder="All Methods" - /> )} + {/* Module Filter */} + { + setModuleIdFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Modules" + /> + {/* Sort Filter - Always show as it's useful for everyone */} { 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" />
- {(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter) && ( + {(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter || moduleIdFilter || search) && (