import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac'; import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePlatform } from '@/platform/hooks/usePlatform'; // === Icons === const BackIcon = () => ( ); const DownloadIcon = () => ( ); const FilterIcon = () => ( ); const ChevronDownIcon = ({ className }: { className?: string }) => ( ); const RefreshIcon = ({ className }: { className?: string }) => ( ); const SearchIcon = () => ( ); // === Constants === const RESOURCE_TYPES = [ 'users', 'tickets', 'stats', 'sales_stats', 'broadcasts', 'tariffs', 'promocodes', 'promo_groups', 'promo_offers', 'campaigns', 'partners', 'withdrawals', 'payments', 'payment_methods', 'servers', 'remnawave', 'traffic', 'settings', 'roles', 'audit_log', 'channels', 'ban_system', 'wheel', 'apps', 'email_templates', 'pinned_messages', 'landings', 'updates', ] as const; const STATUS_OPTIONS = ['success', 'denied', 'error'] as const; const PAGE_SIZE_OPTIONS = [20, 50, 100] as const; const AUTO_REFRESH_INTERVAL = 30_000; interface FiltersState { userId: string; action: string; resource: string; status: string; dateFrom: string; dateTo: string; } const INITIAL_FILTERS: FiltersState = { userId: '', action: '', resource: '', status: '', dateFrom: '', dateTo: '', }; // === Utility functions === function translateAction( action: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any t: any, ): string { return action .split(',') .map((perm: string) => { const trimmed = perm.trim(); const [section, act] = trimmed.split(':', 2); if (!section || !act) return trimmed; const sectionLabel = t(`admin.roles.form.permissionSections.${section}`, section) as string; const actionLabel = t(`admin.roles.form.permissionActions.${act}`, act) as string; return `${sectionLabel}: ${actionLabel}`; }) .join(', '); } function formatRelativeTime( dateString: string, t: (key: string, opts?: Record) => string, ): string { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffSec < 60) return t('admin.auditLog.time.justNow'); if (diffMin < 60) return t('admin.auditLog.time.minutesAgo', { count: diffMin }); if (diffHour < 24) return t('admin.auditLog.time.hoursAgo', { count: diffHour }); if (diffDay < 30) return t('admin.auditLog.time.daysAgo', { count: diffDay }); return date.toLocaleDateString(); } function formatAbsoluteTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleString(); } // === Sub-components === interface StatusBadgeProps { status: string; label: string; } function StatusBadge({ status, label }: StatusBadgeProps) { const colorMap: Record = { success: 'bg-success-500/20 text-success-400', denied: 'bg-error-500/20 text-error-400', error: 'bg-warning-500/20 text-warning-400', }; return ( {label} ); } interface MethodBadgeProps { method: string; } function MethodBadge({ method }: MethodBadgeProps) { const colorMap: Record = { GET: 'bg-accent-500/20 text-accent-400', POST: 'bg-success-500/20 text-success-400', PUT: 'bg-warning-500/20 text-warning-400', PATCH: 'bg-warning-500/20 text-warning-400', DELETE: 'bg-error-500/20 text-error-400', }; return ( {method} ); } interface LogEntryCardProps { entry: AuditLogEntry; isExpanded: boolean; onToggle: () => void; } function LogEntryCard({ entry, isExpanded, onToggle }: LogEntryCardProps) { const { t } = useTranslation(); const status = entry.status; const method = entry.request_method?.toUpperCase() ?? null; const requestPath = entry.request_path; const userName = entry.user_first_name || entry.user_email || t('admin.auditLog.unknownUser'); return (
{/* Main row */} {/* Expanded details */} {isExpanded && (
{/* User agent */} {entry.user_agent && (

{t('admin.auditLog.details.userAgent')}

{entry.user_agent}

)} {/* Request path */} {requestPath && (

{t('admin.auditLog.details.requestPath')}

{requestPath}

)} {/* IP Address */} {entry.ip_address && (

{t('admin.auditLog.details.ipAddress')}

{entry.ip_address}

)} {/* Timestamp */}

{t('admin.auditLog.details.timestamp')}

{formatAbsoluteTime(entry.created_at)}

{/* Before/after diff */} {entry.details && 'before' in entry.details && entry.details.before != null && (

{t('admin.auditLog.details.before')}

                  {JSON.stringify(entry.details.before, null, 2)}
                
)} {entry.details && 'after' in entry.details && entry.details.after != null && (

{t('admin.auditLog.details.after')}

                  {JSON.stringify(entry.details.after, null, 2)}
                
)} {/* Query params */} {entry.details && 'query_params' in entry.details && entry.details.query_params != null && (

{t('admin.auditLog.details.queryParams')}

                    {JSON.stringify(entry.details.query_params, null, 2)}
                  
)} {/* Request body */} {entry.details && 'request_body' in entry.details && entry.details.request_body != null && (

{t('admin.auditLog.details.requestBody')}

                    {JSON.stringify(entry.details.request_body, null, 2)}
                  
)}
{/* Full details JSON */} {entry.details && (

{t('admin.auditLog.details.fullDetails')}

                {JSON.stringify(entry.details, null, 2)}
              
)}
)}
); } // === Main Page === export default function AdminAuditLog() { const { t } = useTranslation(); const navigate = useNavigate(); const { capabilities } = usePlatform(); // Filter state const [filters, setFilters] = useState(INITIAL_FILTERS); const [appliedFilters, setAppliedFilters] = useState(INITIAL_FILTERS); const [filtersOpen, setFiltersOpen] = useState(false); // Pagination const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0]); // UI state const [expandedIds, setExpandedIds] = useState>(new Set()); const [autoRefresh, setAutoRefresh] = useState(false); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); // Build query params const queryParams = useMemo((): AuditLogFilters => { const params: AuditLogFilters = { limit: pageSize, offset: page * pageSize, }; const parsedUserId = parseInt(appliedFilters.userId, 10); if (!isNaN(parsedUserId) && parsedUserId > 0) { params.user_id = parsedUserId; } if (appliedFilters.action.trim()) { params.action = appliedFilters.action.trim(); } if (appliedFilters.resource) { params.resource_type = appliedFilters.resource; } if (appliedFilters.status) { params.status = appliedFilters.status; } if (appliedFilters.dateFrom) { params.date_from = appliedFilters.dateFrom; } if (appliedFilters.dateTo) { params.date_to = appliedFilters.dateTo; } return params; }, [appliedFilters, page, pageSize]); // Main query const { data, isLoading, error, refetch, isFetching } = useQuery({ queryKey: ['admin-audit-log', queryParams], queryFn: () => rbacApi.getAuditLog(queryParams), refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false, }); // RBAC users for filter chips const { data: rbacUsers } = useQuery({ queryKey: ['rbac-users'], queryFn: () => rbacApi.getRbacUsers(), staleTime: 5 * 60 * 1000, }); const entries = data?.items ?? []; const total = data?.total ?? 0; const totalPages = Math.ceil(total / pageSize); // Handlers const handleApplyFilters = useCallback(() => { setAppliedFilters({ ...filters }); setPage(0); setExpandedIds(new Set()); }, [filters]); const handleClearFilters = useCallback(() => { setFilters(INITIAL_FILTERS); setAppliedFilters(INITIAL_FILTERS); setPage(0); setExpandedIds(new Set()); }, []); const handleToggleExpand = useCallback((id: number) => { setExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const handleExport = useCallback(async () => { setExporting(true); try { const exportParams: AuditLogFilters = {}; const exportUserId = parseInt(appliedFilters.userId, 10); if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId; if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim(); if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource; if (appliedFilters.status) exportParams.status = appliedFilters.status; if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom; if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo; const blob = await rbacApi.exportAuditLog(exportParams); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch { setExportError(t('admin.auditLog.exportError')); } finally { setExporting(false); } }, [appliedFilters, t]); const handlePageSizeChange = useCallback((newSize: number) => { setPageSize(newSize); setPage(0); }, []); // Status is filtered server-side via query params const filteredEntries = entries; const hasActiveFilters = useMemo(() => { return ( appliedFilters.userId.trim() !== '' || appliedFilters.action.trim() !== '' || appliedFilters.resource !== '' || appliedFilters.status !== '' || appliedFilters.dateFrom !== '' || appliedFilters.dateTo !== '' ); }, [appliedFilters]); return (
{/* Header */}
{!capabilities.hasBackButton && ( )}

{t('admin.auditLog.title')}

{t('admin.auditLog.subtitle')}

{/* Auto-refresh toggle */} {/* Manual refresh */} {/* Export */}
{exportError &&

{exportError}

}
{/* Filters bar */}
{filtersOpen && (
{/* User filter */} {rbacUsers && rbacUsers.length > 0 && (
{rbacUsers.map((ru) => { const isSelected = filters.userId === String(ru.user_id); const displayName = ru.first_name || ru.email || ru.username || `#${ru.user_id}`; return ( ); })}
)} {/* Action search */}
setFilters((prev) => ({ ...prev, action: e.target.value }))} className="w-full rounded-lg border border-dark-600 bg-dark-900 py-2 pl-9 pr-3 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" placeholder={t('admin.auditLog.filters.actionPlaceholder')} />
{/* Resource type */}
{/* Status */}
{/* Date from */}
setFilters((prev) => ({ ...prev, dateFrom: e.target.value }))} className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500" />
{/* Date to */}
setFilters((prev) => ({ ...prev, dateTo: e.target.value }))} className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500" />
{/* Filter actions */}
)}
{/* Results summary */} {!isLoading && !error && (

{t('admin.auditLog.totalEntries', { count: total })}

)} {/* Content */} {isLoading ? (
) : error ? (

{t('admin.auditLog.errors.loadFailed')}

) : filteredEntries.length === 0 ? (

{t('admin.auditLog.noEntries')}

) : (
{filteredEntries.map((entry) => ( handleToggleExpand(entry.id)} /> ))}
)} {/* Pagination */} {totalPages > 1 && (
{t('admin.auditLog.pagination.pageOf', { current: page + 1, total: totalPages, })}
)}
); }