mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Sweep non-brand blue across admin pages, AnalyticsTab, TvQuickConnect, and menuLayout style picker to accent tokens. Telegram-brand blue kept in BroadcastPreview (TG chat mockup), the blocking screens (TG channel/bot CTAs), and the SuccessNotificationModal hero.
926 lines
33 KiB
TypeScript
926 lines
33 KiB
TypeScript
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 = () => (
|
|
<svg
|
|
className="h-5 w-5 text-dark-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const DownloadIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const FilterIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronDownIcon = ({ className }: { className?: string }) => (
|
|
<svg
|
|
className={className || 'h-4 w-4'}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const RefreshIcon = ({ className }: { className?: string }) => (
|
|
<svg
|
|
className={className || 'h-4 w-4'}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const SearchIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
// === 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, unknown>) => 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<string, string> = {
|
|
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 (
|
|
<span
|
|
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${colorMap[status] || 'bg-dark-600 text-dark-300'}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface MethodBadgeProps {
|
|
method: string;
|
|
}
|
|
|
|
function MethodBadge({ method }: MethodBadgeProps) {
|
|
const colorMap: Record<string, string> = {
|
|
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 (
|
|
<span
|
|
className={`inline-flex items-center rounded-md px-2 py-0.5 font-mono text-xs font-medium ${colorMap[method] || 'bg-dark-600 text-dark-300'}`}
|
|
>
|
|
{method}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600">
|
|
{/* Main row */}
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="flex w-full flex-col gap-2 p-4 text-left sm:flex-row sm:items-center sm:gap-4"
|
|
aria-expanded={isExpanded}
|
|
>
|
|
{/* Timestamp */}
|
|
<div
|
|
className="shrink-0 text-sm text-dark-400"
|
|
title={formatAbsoluteTime(entry.created_at)}
|
|
>
|
|
{formatRelativeTime(entry.created_at, t)}
|
|
</div>
|
|
|
|
{/* User */}
|
|
<div className="min-w-0 shrink-0">
|
|
<span className="text-sm font-medium text-dark-200">{userName}</span>
|
|
</div>
|
|
|
|
{/* Action + status */}
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
<StatusBadge
|
|
status={status}
|
|
label={t(`admin.auditLog.status.${status}`, { defaultValue: status })}
|
|
/>
|
|
<span className="truncate text-sm font-medium text-dark-100">
|
|
{translateAction(entry.action, t)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Resource */}
|
|
<div className="flex shrink-0 items-center gap-2 text-sm text-dark-400">
|
|
<span>
|
|
{entry.resource_type
|
|
? t(`admin.roles.form.permissionSections.${entry.resource_type}`, entry.resource_type)
|
|
: null}
|
|
</span>
|
|
{entry.resource_id && (
|
|
<span className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-dark-300">
|
|
#{entry.resource_id}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Method badge */}
|
|
{method && (
|
|
<div className="shrink-0">
|
|
<MethodBadge method={method} />
|
|
</div>
|
|
)}
|
|
|
|
{/* IP */}
|
|
{entry.ip_address && (
|
|
<div className="shrink-0 font-mono text-xs text-dark-500">{entry.ip_address}</div>
|
|
)}
|
|
|
|
{/* Expand indicator */}
|
|
<ChevronDownIcon
|
|
className={`h-4 w-4 shrink-0 text-dark-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Expanded details */}
|
|
{isExpanded && (
|
|
<div className="border-t border-dark-700 p-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{/* User agent */}
|
|
{entry.user_agent && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.userAgent')}
|
|
</p>
|
|
<p className="break-all text-sm text-dark-300">{entry.user_agent}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Request path */}
|
|
{requestPath && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.requestPath')}
|
|
</p>
|
|
<p className="break-all font-mono text-sm text-dark-300">{requestPath}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* IP Address */}
|
|
{entry.ip_address && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.ipAddress')}
|
|
</p>
|
|
<p className="font-mono text-sm text-dark-300">{entry.ip_address}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timestamp */}
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.timestamp')}
|
|
</p>
|
|
<p className="text-sm text-dark-300">{formatAbsoluteTime(entry.created_at)}</p>
|
|
</div>
|
|
|
|
{/* Before/after diff */}
|
|
{entry.details && 'before' in entry.details && entry.details.before != null && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.before')}
|
|
</p>
|
|
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
|
|
{JSON.stringify(entry.details.before, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{entry.details && 'after' in entry.details && entry.details.after != null && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.after')}
|
|
</p>
|
|
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
|
|
{JSON.stringify(entry.details.after, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* Query params */}
|
|
{entry.details &&
|
|
'query_params' in entry.details &&
|
|
entry.details.query_params != null && (
|
|
<div className="sm:col-span-2">
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.queryParams')}
|
|
</p>
|
|
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
|
|
{JSON.stringify(entry.details.query_params, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* Request body */}
|
|
{entry.details &&
|
|
'request_body' in entry.details &&
|
|
entry.details.request_body != null && (
|
|
<div className="sm:col-span-2">
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.requestBody')}
|
|
</p>
|
|
<pre className="max-h-60 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
|
|
{JSON.stringify(entry.details.request_body, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Full details JSON */}
|
|
{entry.details && (
|
|
<div className="mt-4">
|
|
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
|
|
{t('admin.auditLog.details.fullDetails')}
|
|
</p>
|
|
<pre className="max-h-60 overflow-auto rounded-lg bg-dark-900 p-3 text-xs text-dark-300">
|
|
{JSON.stringify(entry.details, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Main Page ===
|
|
|
|
export default function AdminAuditLog() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { capabilities } = usePlatform();
|
|
|
|
// Filter state
|
|
const [filters, setFilters] = useState<FiltersState>(INITIAL_FILTERS);
|
|
const [appliedFilters, setAppliedFilters] = useState<FiltersState>(INITIAL_FILTERS);
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
|
|
// Pagination
|
|
const [page, setPage] = useState(0);
|
|
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE_OPTIONS[0]);
|
|
|
|
// UI state
|
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [exportError, setExportError] = useState<string | null>(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 (
|
|
<div className="animate-fade-in">
|
|
{/* Header */}
|
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{!capabilities.hasBackButton && (
|
|
<button
|
|
onClick={() => navigate('/admin')}
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
|
aria-label={t('admin.auditLog.back')}
|
|
>
|
|
<BackIcon />
|
|
</button>
|
|
)}
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-dark-100">{t('admin.auditLog.title')}</h1>
|
|
<p className="text-sm text-dark-400">{t('admin.auditLog.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Auto-refresh toggle */}
|
|
<button
|
|
onClick={() => setAutoRefresh((prev) => !prev)}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
|
autoRefresh
|
|
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
|
}`}
|
|
title={t('admin.auditLog.autoRefresh.tooltip')}
|
|
>
|
|
<RefreshIcon className={`h-4 w-4 ${isFetching && autoRefresh ? 'animate-spin' : ''}`} />
|
|
<span className="hidden sm:inline">{t('admin.auditLog.autoRefresh.label')}</span>
|
|
</button>
|
|
|
|
{/* Manual refresh */}
|
|
<button
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-300 disabled:opacity-50"
|
|
title={t('admin.auditLog.refresh')}
|
|
>
|
|
<RefreshIcon className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
|
|
{/* Export */}
|
|
<PermissionGate permission="audit_log:export">
|
|
<div className="flex items-center gap-2">
|
|
{exportError && <p className="text-sm text-error-400">{exportError}</p>}
|
|
<button
|
|
onClick={() => {
|
|
setExportError(null);
|
|
handleExport();
|
|
}}
|
|
disabled={exporting}
|
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
|
>
|
|
<DownloadIcon />
|
|
<span className="hidden sm:inline">
|
|
{exporting ? t('admin.auditLog.exporting') : t('admin.auditLog.exportCsv')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</PermissionGate>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters bar */}
|
|
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFiltersOpen((prev) => !prev)}
|
|
className="flex w-full items-center justify-between p-4"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FilterIcon />
|
|
<span className="text-sm font-medium text-dark-200">
|
|
{t('admin.auditLog.filters.title')}
|
|
</span>
|
|
{hasActiveFilters && (
|
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-medium text-accent-400">
|
|
{t('admin.auditLog.filters.active')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<ChevronDownIcon
|
|
className={`h-5 w-5 text-dark-400 transition-transform ${filtersOpen ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{filtersOpen && (
|
|
<div className="border-t border-dark-700 p-4">
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{/* User filter */}
|
|
{rbacUsers && rbacUsers.length > 0 && (
|
|
<div className="sm:col-span-2 lg:col-span-3">
|
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
|
{t('admin.auditLog.filters.user')}
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{rbacUsers.map((ru) => {
|
|
const isSelected = filters.userId === String(ru.user_id);
|
|
const displayName =
|
|
ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
|
|
return (
|
|
<button
|
|
key={ru.user_id}
|
|
type="button"
|
|
aria-pressed={isSelected}
|
|
onClick={() =>
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
userId: isSelected ? '' : String(ru.user_id),
|
|
}))
|
|
}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1 focus-visible:ring-offset-dark-900 ${
|
|
isSelected
|
|
? 'border-accent-500 bg-accent-500/20 text-accent-300'
|
|
: 'border-dark-600 bg-dark-900 text-dark-300 hover:border-dark-500 hover:text-dark-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
|
|
isSelected
|
|
? 'border-accent-500 bg-accent-500 text-white'
|
|
: 'border-dark-500 bg-dark-800'
|
|
}`}
|
|
>
|
|
{isSelected && '✓'}
|
|
</span>
|
|
<span>{displayName}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action search */}
|
|
<div>
|
|
<label
|
|
htmlFor="filter-action"
|
|
className="mb-1 block text-sm font-medium text-dark-300"
|
|
>
|
|
{t('admin.auditLog.filters.action')}
|
|
</label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
|
<SearchIcon />
|
|
</span>
|
|
<input
|
|
id="filter-action"
|
|
type="text"
|
|
value={filters.action}
|
|
onChange={(e) => 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')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resource type */}
|
|
<div>
|
|
<label
|
|
htmlFor="filter-resource"
|
|
className="mb-1 block text-sm font-medium text-dark-300"
|
|
>
|
|
{t('admin.auditLog.filters.resource')}
|
|
</label>
|
|
<select
|
|
id="filter-resource"
|
|
value={filters.resource}
|
|
onChange={(e) => setFilters((prev) => ({ ...prev, resource: 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"
|
|
>
|
|
<option value="">{t('admin.auditLog.filters.allResources')}</option>
|
|
{RESOURCE_TYPES.map((type) => (
|
|
<option key={type} value={type}>
|
|
{t(`admin.roles.form.permissionSections.${type}`, type)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div>
|
|
<label
|
|
htmlFor="filter-status"
|
|
className="mb-1 block text-sm font-medium text-dark-300"
|
|
>
|
|
{t('admin.auditLog.filters.status')}
|
|
</label>
|
|
<select
|
|
id="filter-status"
|
|
value={filters.status}
|
|
onChange={(e) => setFilters((prev) => ({ ...prev, status: 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"
|
|
>
|
|
<option value="">{t('admin.auditLog.filters.allStatuses')}</option>
|
|
{STATUS_OPTIONS.map((status) => (
|
|
<option key={status} value={status}>
|
|
{t(`admin.auditLog.status.${status}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Date from */}
|
|
<div>
|
|
<label
|
|
htmlFor="filter-date-from"
|
|
className="mb-1 block text-sm font-medium text-dark-300"
|
|
>
|
|
{t('admin.auditLog.filters.dateFrom')}
|
|
</label>
|
|
<input
|
|
id="filter-date-from"
|
|
type="date"
|
|
value={filters.dateFrom}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Date to */}
|
|
<div>
|
|
<label
|
|
htmlFor="filter-date-to"
|
|
className="mb-1 block text-sm font-medium text-dark-300"
|
|
>
|
|
{t('admin.auditLog.filters.dateTo')}
|
|
</label>
|
|
<input
|
|
id="filter-date-to"
|
|
type="date"
|
|
value={filters.dateTo}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter actions */}
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
onClick={handleApplyFilters}
|
|
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
|
>
|
|
{t('admin.auditLog.filters.apply')}
|
|
</button>
|
|
<button
|
|
onClick={handleClearFilters}
|
|
className="rounded-lg border border-dark-600 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:text-dark-200"
|
|
>
|
|
{t('admin.auditLog.filters.clear')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results summary */}
|
|
{!isLoading && !error && (
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm text-dark-400">
|
|
{t('admin.auditLog.totalEntries', { count: total })}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="page-size" className="text-sm text-dark-500">
|
|
{t('admin.auditLog.pagination.pageSize')}
|
|
</label>
|
|
<select
|
|
id="page-size"
|
|
value={pageSize}
|
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
|
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200 outline-none focus:border-accent-500"
|
|
>
|
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
<option key={size} value={size}>
|
|
{size}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="py-12 text-center">
|
|
<p className="text-error-400">{t('admin.auditLog.errors.loadFailed')}</p>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="mt-3 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
|
>
|
|
{t('admin.auditLog.errors.retry')}
|
|
</button>
|
|
</div>
|
|
) : filteredEntries.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<p className="text-dark-400">{t('admin.auditLog.noEntries')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{filteredEntries.map((entry) => (
|
|
<LogEntryCard
|
|
key={entry.id}
|
|
entry={entry}
|
|
isExpanded={expandedIds.has(entry.id)}
|
|
onToggle={() => handleToggleExpand(entry.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-6 flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={() => setPage(0)}
|
|
disabled={page === 0}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={t('admin.auditLog.pagination.first')}
|
|
>
|
|
«
|
|
</button>
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={t('admin.auditLog.pagination.previous')}
|
|
>
|
|
‹
|
|
</button>
|
|
|
|
<span className="px-3 py-2 text-sm text-dark-300">
|
|
{t('admin.auditLog.pagination.pageOf', {
|
|
current: page + 1,
|
|
total: totalPages,
|
|
})}
|
|
</span>
|
|
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
disabled={page >= totalPages - 1}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={t('admin.auditLog.pagination.next')}
|
|
>
|
|
›
|
|
</button>
|
|
<button
|
|
onClick={() => setPage(totalPages - 1)}
|
|
disabled={page >= totalPages - 1}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={t('admin.auditLog.pagination.last')}
|
|
>
|
|
»
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|