ET-AUDIT-FRONTEND: Componentes React para Auditoria
Identificacion
| Campo |
Valor |
| ID |
ET-AUDIT-FRONTEND |
| Modulo |
MGN-007 Audit |
| Version |
1.0 |
| Estado |
En Diseno |
| Framework |
React 18 + TypeScript |
| UI Library |
shadcn/ui |
| State |
Zustand 4+ |
| Autor |
Requirements-Analyst |
| Fecha |
2025-12-05 |
Estructura de Archivos
apps/frontend/src/modules/audit/
├── index.ts
├── types/
│ ├── audit-log.types.ts
│ ├── access-log.types.ts
│ ├── security-event.types.ts
│ └── scheduled-report.types.ts
├── stores/
│ ├── audit-logs.store.ts
│ ├── access-logs.store.ts
│ ├── security-events.store.ts
│ └── scheduled-reports.store.ts
├── hooks/
│ ├── useAuditLogs.ts
│ ├── useAccessLogs.ts
│ ├── useSecurityEvents.ts
│ ├── useEntityHistory.ts
│ └── useAuditExport.ts
├── components/
│ ├── AuditLogViewer/
│ │ ├── AuditLogViewer.tsx
│ │ ├── AuditLogFilters.tsx
│ │ ├── AuditLogRow.tsx
│ │ ├── AuditLogDetail.tsx
│ │ └── AuditDiffViewer.tsx
│ ├── AccessLogViewer/
│ │ ├── AccessLogViewer.tsx
│ │ ├── AccessLogFilters.tsx
│ │ └── AccessLogStats.tsx
│ ├── SecurityDashboard/
│ │ ├── SecurityDashboard.tsx
│ │ ├── SecurityEventCard.tsx
│ │ ├── SecurityEventDetail.tsx
│ │ ├── SeverityBadge.tsx
│ │ └── SecurityTimeline.tsx
│ ├── EntityHistory/
│ │ ├── EntityHistoryPanel.tsx
│ │ ├── EntityHistoryTimeline.tsx
│ │ └── ChangeCard.tsx
│ ├── ScheduledReports/
│ │ ├── ScheduledReportsList.tsx
│ │ ├── ScheduledReportForm.tsx
│ │ └── CronScheduleBuilder.tsx
│ └── shared/
│ ├── DateRangePicker.tsx
│ ├── ExportButton.tsx
│ ├── JsonViewer.tsx
│ └── UserAvatar.tsx
├── pages/
│ ├── AuditLogsPage.tsx
│ ├── AccessLogsPage.tsx
│ ├── SecurityEventsPage.tsx
│ └── ScheduledReportsPage.tsx
└── routes.tsx
Tipos TypeScript
Audit Log Types
// types/audit-log.types.ts
export type AuditAction = 'create' | 'update' | 'delete' | 'restore';
export interface AuditLog {
id: string;
tenantId: string;
userId: string | null;
action: AuditAction;
entityType: string;
entityId: string;
oldValues: Record<string, any> | null;
newValues: Record<string, any> | null;
changedFields: string[] | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
createdAt: string;
// Joined data
user?: {
id: string;
fullName: string;
email: string;
avatarUrl?: string;
};
}
export interface AuditLogFilters {
entityType?: string;
entityId?: string;
userId?: string;
action?: AuditAction;
dateFrom?: string;
dateTo?: string;
search?: string;
}
export interface AuditDiff {
field: string;
oldValue: any;
newValue: any;
type: 'added' | 'removed' | 'changed';
}
export interface EntityStats {
entityType: string;
count: number;
}
export interface ActionStats {
action: AuditAction;
count: number;
}
Security Event Types
// types/security-event.types.ts
export type SecuritySeverity = 'low' | 'medium' | 'high' | 'critical';
export type SecurityEventType =
| 'login_failed'
| 'login_blocked'
| 'brute_force_detected'
| 'password_changed'
| 'password_reset_requested'
| 'session_hijack_attempt'
| 'concurrent_session'
| 'session_from_new_location'
| 'session_from_new_device'
| 'permission_escalation'
| 'role_changed'
| 'admin_created'
| 'mass_delete_attempt'
| 'data_export'
| 'sensitive_data_access'
| 'api_key_created'
| 'webhook_modified'
| 'settings_changed';
export interface SecurityEvent {
id: string;
tenantId: string | null;
userId: string | null;
eventType: SecurityEventType;
severity: SecuritySeverity;
title: string;
description: string | null;
metadata: Record<string, any>;
ipAddress: string | null;
countryCode: string | null;
isResolved: boolean;
resolvedBy: string | null;
resolvedAt: string | null;
resolutionNotes: string | null;
createdAt: string;
// Joined data
user?: {
id: string;
fullName: string;
email: string;
};
resolvedByUser?: {
id: string;
fullName: string;
};
}
export interface SecurityEventFilters {
severity?: SecuritySeverity;
eventType?: SecurityEventType;
isResolved?: boolean;
dateFrom?: string;
userId?: string;
}
export interface SeverityCounts {
low: number;
medium: number;
high: number;
critical: number;
total: number;
}
Access Log Types
// types/access-log.types.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface AccessLog {
id: string;
tenantId: string;
userId: string | null;
sessionId: string | null;
requestId: string;
method: HttpMethod;
path: string;
queryParams: Record<string, string> | null;
statusCode: number;
responseTimeMs: number;
ipAddress: string;
userAgent: string | null;
referer: string | null;
createdAt: string;
// Joined data
user?: {
id: string;
fullName: string;
email: string;
};
}
export interface AccessLogFilters {
userId?: string;
path?: string;
method?: HttpMethod;
statusCode?: number;
minResponseTime?: number;
dateFrom?: string;
dateTo?: string;
}
export interface AccessStats {
period: string;
totalRequests: number;
avgResponseTime: number;
errorCount: number;
}
export interface EndpointStats {
method: HttpMethod;
path: string;
count: number;
avgResponseTime: number;
}
Scheduled Report Types
// types/scheduled-report.types.ts
export type AuditReportType = 'activity' | 'security' | 'compliance' | 'change' | 'access';
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';
export interface ScheduledReport {
id: string;
tenantId: string;
name: string;
reportType: AuditReportType;
filters: Record<string, any>;
format: ExportFormat;
schedule: string; // cron expression
recipients: string[];
isActive: boolean;
lastRunAt: string | null;
nextRunAt: string | null;
createdBy: string;
createdAt: string;
}
export interface CreateScheduledReportDto {
name: string;
reportType: AuditReportType;
filters: Record<string, any>;
format: ExportFormat;
schedule: string;
recipients: string[];
}
Stores (Zustand)
Audit Logs Store
// stores/audit-logs.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { auditApi } from '../api/audit.api';
import type { AuditLog, AuditLogFilters, ActionStats, EntityStats } from '../types';
interface AuditLogsState {
// Data
logs: AuditLog[];
selectedLog: AuditLog | null;
entityHistory: AuditLog[];
actionStats: ActionStats[];
entityStats: EntityStats[];
// UI State
filters: AuditLogFilters;
isLoading: boolean;
isExporting: boolean;
error: string | null;
// Pagination
page: number;
limit: number;
total: number;
// Actions
fetchLogs: () => Promise<void>;
fetchEntityHistory: (entityType: string, entityId: string) => Promise<void>;
fetchStats: (dateFrom: Date, dateTo: Date) => Promise<void>;
setFilters: (filters: Partial<AuditLogFilters>) => void;
clearFilters: () => void;
setPage: (page: number) => void;
selectLog: (log: AuditLog | null) => void;
exportLogs: (format: 'csv' | 'xlsx' | 'pdf') => Promise<void>;
}
export const useAuditLogsStore = create<AuditLogsState>()(
devtools(
(set, get) => ({
// Initial State
logs: [],
selectedLog: null,
entityHistory: [],
actionStats: [],
entityStats: [],
filters: {},
isLoading: false,
isExporting: false,
error: null,
page: 1,
limit: 50,
total: 0,
fetchLogs: async () => {
const { filters, page, limit } = get();
set({ isLoading: true, error: null });
try {
const response = await auditApi.getLogs({
...filters,
page,
limit,
});
set({
logs: response.items,
total: response.total,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch logs',
isLoading: false,
});
}
},
fetchEntityHistory: async (entityType: string, entityId: string) => {
set({ isLoading: true, error: null });
try {
const history = await auditApi.getEntityHistory(entityType, entityId);
set({ entityHistory: history, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch history',
isLoading: false,
});
}
},
fetchStats: async (dateFrom: Date, dateTo: Date) => {
try {
const [actionStats, entityStats] = await Promise.all([
auditApi.getStatsByAction(dateFrom, dateTo),
auditApi.getStatsByEntity(dateFrom, dateTo),
]);
set({ actionStats, entityStats });
} catch (error) {
console.error('Failed to fetch stats:', error);
}
},
setFilters: (newFilters) => {
set((state) => ({
filters: { ...state.filters, ...newFilters },
page: 1, // Reset to first page
}));
get().fetchLogs();
},
clearFilters: () => {
set({ filters: {}, page: 1 });
get().fetchLogs();
},
setPage: (page) => {
set({ page });
get().fetchLogs();
},
selectLog: (log) => set({ selectedLog: log }),
exportLogs: async (format) => {
const { filters } = get();
set({ isExporting: true });
try {
const blob = await auditApi.exportLogs({ ...filters, format });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`;
a.click();
URL.revokeObjectURL(url);
} finally {
set({ isExporting: false });
}
},
}),
{ name: 'audit-logs-store' }
)
);
Security Events Store
// stores/security-events.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { auditApi } from '../api/audit.api';
import type { SecurityEvent, SecurityEventFilters, SeverityCounts } from '../types';
interface SecurityEventsState {
// Data
events: SecurityEvent[];
selectedEvent: SecurityEvent | null;
counts: SeverityCounts;
// UI State
filters: SecurityEventFilters;
isLoading: boolean;
isResolving: boolean;
error: string | null;
// Pagination
page: number;
limit: number;
total: number;
// Actions
fetchEvents: () => Promise<void>;
fetchCounts: () => Promise<void>;
setFilters: (filters: Partial<SecurityEventFilters>) => void;
clearFilters: () => void;
setPage: (page: number) => void;
selectEvent: (event: SecurityEvent | null) => void;
resolveEvent: (id: string, notes?: string) => Promise<void>;
}
export const useSecurityEventsStore = create<SecurityEventsState>()(
devtools(
(set, get) => ({
// Initial State
events: [],
selectedEvent: null,
counts: { low: 0, medium: 0, high: 0, critical: 0, total: 0 },
filters: { isResolved: false },
isLoading: false,
isResolving: false,
error: null,
page: 1,
limit: 25,
total: 0,
fetchEvents: async () => {
const { filters, page, limit } = get();
set({ isLoading: true, error: null });
try {
const response = await auditApi.getSecurityEvents({
...filters,
page,
limit,
});
set({
events: response.items,
total: response.total,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch events',
isLoading: false,
});
}
},
fetchCounts: async () => {
try {
const counts = await auditApi.getSecurityCounts();
set({
counts: {
...counts,
total: counts.low + counts.medium + counts.high + counts.critical,
},
});
} catch (error) {
console.error('Failed to fetch counts:', error);
}
},
setFilters: (newFilters) => {
set((state) => ({
filters: { ...state.filters, ...newFilters },
page: 1,
}));
get().fetchEvents();
},
clearFilters: () => {
set({ filters: { isResolved: false }, page: 1 });
get().fetchEvents();
},
setPage: (page) => {
set({ page });
get().fetchEvents();
},
selectEvent: (event) => set({ selectedEvent: event }),
resolveEvent: async (id: string, notes?: string) => {
set({ isResolving: true });
try {
await auditApi.resolveSecurityEvent(id, { notes });
// Update local state
set((state) => ({
events: state.events.map((e) =>
e.id === id ? { ...e, isResolved: true, resolutionNotes: notes || null } : e
),
selectedEvent: state.selectedEvent?.id === id
? { ...state.selectedEvent, isResolved: true, resolutionNotes: notes || null }
: state.selectedEvent,
}));
// Refresh counts
get().fetchCounts();
} finally {
set({ isResolving: false });
}
},
}),
{ name: 'security-events-store' }
)
);
Access Logs Store
// stores/access-logs.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { auditApi } from '../api/audit.api';
import type { AccessLog, AccessLogFilters, AccessStats, EndpointStats } from '../types';
interface AccessLogsState {
// Data
logs: AccessLog[];
stats: AccessStats[];
topEndpoints: EndpointStats[];
// UI State
filters: AccessLogFilters;
isLoading: boolean;
error: string | null;
// Chart settings
groupBy: 'hour' | 'day';
// Pagination
page: number;
limit: number;
total: number;
// Actions
fetchLogs: () => Promise<void>;
fetchStats: (dateFrom: Date, dateTo: Date) => Promise<void>;
fetchTopEndpoints: (dateFrom: Date, limit?: number) => Promise<void>;
setFilters: (filters: Partial<AccessLogFilters>) => void;
clearFilters: () => void;
setGroupBy: (groupBy: 'hour' | 'day') => void;
setPage: (page: number) => void;
}
export const useAccessLogsStore = create<AccessLogsState>()(
devtools(
(set, get) => ({
// Initial State
logs: [],
stats: [],
topEndpoints: [],
filters: {},
isLoading: false,
error: null,
groupBy: 'hour',
page: 1,
limit: 100,
total: 0,
fetchLogs: async () => {
const { filters, page, limit } = get();
set({ isLoading: true, error: null });
try {
const response = await auditApi.getAccessLogs({
...filters,
page,
limit,
});
set({
logs: response.items,
total: response.total,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch logs',
isLoading: false,
});
}
},
fetchStats: async (dateFrom: Date, dateTo: Date) => {
const { groupBy } = get();
try {
const stats = await auditApi.getAccessStats(dateFrom, dateTo, groupBy);
set({ stats });
} catch (error) {
console.error('Failed to fetch stats:', error);
}
},
fetchTopEndpoints: async (dateFrom: Date, limit = 10) => {
try {
const topEndpoints = await auditApi.getTopEndpoints(dateFrom, limit);
set({ topEndpoints });
} catch (error) {
console.error('Failed to fetch top endpoints:', error);
}
},
setFilters: (newFilters) => {
set((state) => ({
filters: { ...state.filters, ...newFilters },
page: 1,
}));
get().fetchLogs();
},
clearFilters: () => {
set({ filters: {}, page: 1 });
get().fetchLogs();
},
setGroupBy: (groupBy) => set({ groupBy }),
setPage: (page) => {
set({ page });
get().fetchLogs();
},
}),
{ name: 'access-logs-store' }
)
);
Hooks Personalizados
useEntityHistory
// hooks/useEntityHistory.ts
import { useEffect, useCallback } from 'react';
import { useAuditLogsStore } from '../stores/audit-logs.store';
interface UseEntityHistoryOptions {
entityType: string;
entityId: string;
autoFetch?: boolean;
}
export function useEntityHistory(options: UseEntityHistoryOptions) {
const { entityType, entityId, autoFetch = true } = options;
const {
entityHistory,
isLoading,
error,
fetchEntityHistory,
} = useAuditLogsStore();
const fetch = useCallback(() => {
fetchEntityHistory(entityType, entityId);
}, [entityType, entityId, fetchEntityHistory]);
useEffect(() => {
if (autoFetch && entityType && entityId) {
fetch();
}
}, [autoFetch, entityType, entityId, fetch]);
return {
history: entityHistory,
isLoading,
error,
refetch: fetch,
};
}
useAuditExport
// hooks/useAuditExport.ts
import { useState, useCallback } from 'react';
import { auditApi } from '../api/audit.api';
import type { AuditLogFilters, ExportFormat } from '../types';
export function useAuditExport() {
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const exportLogs = useCallback(async (
filters: AuditLogFilters,
format: ExportFormat,
filename?: string
) => {
setIsExporting(true);
setError(null);
try {
const blob = await auditApi.exportLogs({ ...filters, format });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || `audit-export-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed');
return false;
} finally {
setIsExporting(false);
}
}, []);
return {
exportLogs,
isExporting,
error,
};
}
useSecurityAlerts
// hooks/useSecurityAlerts.ts
import { useEffect, useRef } from 'react';
import { useSecurityEventsStore } from '../stores/security-events.store';
import { useToast } from '@/components/ui/use-toast';
export function useSecurityAlerts() {
const { counts, fetchCounts } = useSecurityEventsStore();
const { toast } = useToast();
const previousCritical = useRef(counts.critical);
// Poll for new events
useEffect(() => {
const interval = setInterval(() => {
fetchCounts();
}, 30000); // Every 30 seconds
return () => clearInterval(interval);
}, [fetchCounts]);
// Show toast when critical count increases
useEffect(() => {
if (counts.critical > previousCritical.current) {
const newEvents = counts.critical - previousCritical.current;
toast({
variant: 'destructive',
title: 'Critical Security Alert',
description: `${newEvents} new critical security event${newEvents > 1 ? 's' : ''} detected`,
});
}
previousCritical.current = counts.critical;
}, [counts.critical, toast]);
return {
hasUnresolvedCritical: counts.critical > 0,
hasUnresolvedHigh: counts.high > 0,
totalUnresolved: counts.total,
counts,
};
}
Componentes React
AuditLogViewer
// components/AuditLogViewer/AuditLogViewer.tsx
import { useEffect } from 'react';
import { useAuditLogsStore } from '../../stores/audit-logs.store';
import { AuditLogFilters } from './AuditLogFilters';
import { AuditLogRow } from './AuditLogRow';
import { AuditLogDetail } from './AuditLogDetail';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Pagination } from '@/components/ui/pagination';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
export function AuditLogViewer() {
const {
logs,
selectedLog,
isLoading,
error,
page,
limit,
total,
fetchLogs,
setPage,
selectLog,
} = useAuditLogsStore();
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const totalPages = Math.ceil(total / limit);
if (error) {
return (
<div className="p-4 text-center text-destructive">
Error: {error}
</div>
);
}
return (
<div className="space-y-4">
<AuditLogFilters />
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>User</TableHead>
<TableHead className="w-[120px]">IP Address</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<td colSpan={5} className="p-2">
<Skeleton className="h-8 w-full" />
</td>
</TableRow>
))
) : logs.length === 0 ? (
<TableRow>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
No audit logs found
</td>
</TableRow>
) : (
logs.map((log) => (
<AuditLogRow
key={log.id}
log={log}
onClick={() => selectLog(log)}
isSelected={selectedLog?.id === log.id}
/>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
<Sheet open={!!selectedLog} onOpenChange={() => selectLog(null)}>
<SheetContent className="w-[600px] sm:max-w-[600px]">
{selectedLog && <AuditLogDetail log={selectedLog} />}
</SheetContent>
</Sheet>
</div>
);
}
AuditDiffViewer
// components/AuditLogViewer/AuditDiffViewer.tsx
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import type { AuditDiff } from '../../types';
interface AuditDiffViewerProps {
oldValues: Record<string, any> | null;
newValues: Record<string, any> | null;
changedFields?: string[] | null;
}
export function AuditDiffViewer({
oldValues,
newValues,
changedFields,
}: AuditDiffViewerProps) {
const diffs = useMemo(() => {
const result: AuditDiff[] = [];
const fields = changedFields || Object.keys({ ...oldValues, ...newValues });
for (const field of fields) {
const oldVal = oldValues?.[field];
const newVal = newValues?.[field];
if (oldVal === undefined && newVal !== undefined) {
result.push({ field, oldValue: null, newValue: newVal, type: 'added' });
} else if (oldVal !== undefined && newVal === undefined) {
result.push({ field, oldValue: oldVal, newValue: null, type: 'removed' });
} else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
result.push({ field, oldValue: oldVal, newValue: newVal, type: 'changed' });
}
}
return result;
}, [oldValues, newValues, changedFields]);
if (diffs.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No changes detected
</div>
);
}
return (
<div className="space-y-2">
{diffs.map((diff) => (
<div
key={diff.field}
className={cn(
'rounded-md border p-3',
diff.type === 'added' && 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',
diff.type === 'removed' && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950',
diff.type === 'changed' && 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950'
)}
>
<div className="mb-1 font-medium text-sm">
{diff.field}
<span className="ml-2 text-xs text-muted-foreground">
({diff.type})
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-xs text-muted-foreground block mb-1">Before</span>
<code className={cn(
'block p-2 rounded text-xs overflow-auto max-h-32',
diff.type === 'added' ? 'text-muted-foreground' : 'bg-background'
)}>
{diff.oldValue !== null ? formatValue(diff.oldValue) : '(empty)'}
</code>
</div>
<div>
<span className="text-xs text-muted-foreground block mb-1">After</span>
<code className={cn(
'block p-2 rounded text-xs overflow-auto max-h-32',
diff.type === 'removed' ? 'text-muted-foreground' : 'bg-background'
)}>
{diff.newValue !== null ? formatValue(diff.newValue) : '(empty)'}
</code>
</div>
</div>
</div>
))}
</div>
);
}
function formatValue(value: any): string {
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
}
SecurityDashboard
// components/SecurityDashboard/SecurityDashboard.tsx
import { useEffect } from 'react';
import { useSecurityEventsStore } from '../../stores/security-events.store';
import { SecurityEventCard } from './SecurityEventCard';
import { SecurityEventDetail } from './SecurityEventDetail';
import { SeverityBadge } from './SeverityBadge';
import { SecurityTimeline } from './SecurityTimeline';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { AlertTriangle, Shield, ShieldCheck, ShieldX } from 'lucide-react';
export function SecurityDashboard() {
const {
events,
selectedEvent,
counts,
filters,
isLoading,
fetchEvents,
fetchCounts,
setFilters,
selectEvent,
resolveEvent,
} = useSecurityEventsStore();
useEffect(() => {
fetchEvents();
fetchCounts();
}, [fetchEvents, fetchCounts]);
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<SeverityCard
title="Critical"
count={counts.critical}
icon={ShieldX}
variant="critical"
/>
<SeverityCard
title="High"
count={counts.high}
icon={AlertTriangle}
variant="high"
/>
<SeverityCard
title="Medium"
count={counts.medium}
icon={Shield}
variant="medium"
/>
<SeverityCard
title="Low"
count={counts.low}
icon={ShieldCheck}
variant="low"
/>
</div>
{/* Tabs for filtering */}
<Tabs
value={filters.isResolved === false ? 'unresolved' : 'all'}
onValueChange={(v) => setFilters({
isResolved: v === 'unresolved' ? false : undefined,
})}
>
<TabsList>
<TabsTrigger value="unresolved">
Unresolved
{counts.total > 0 && (
<Badge variant="destructive" className="ml-2">
{counts.total}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="all">All Events</TabsTrigger>
</TabsList>
<TabsContent value="unresolved" className="mt-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{events.map((event) => (
<SecurityEventCard
key={event.id}
event={event}
onClick={() => selectEvent(event)}
/>
))}
</div>
</TabsContent>
<TabsContent value="all" className="mt-4">
<SecurityTimeline events={events} onSelect={selectEvent} />
</TabsContent>
</Tabs>
{/* Detail Sheet */}
<Sheet open={!!selectedEvent} onOpenChange={() => selectEvent(null)}>
<SheetContent className="w-[500px] sm:max-w-[500px]">
{selectedEvent && (
<SecurityEventDetail
event={selectedEvent}
onResolve={(notes) => resolveEvent(selectedEvent.id, notes)}
/>
)}
</SheetContent>
</Sheet>
</div>
);
}
interface SeverityCardProps {
title: string;
count: number;
icon: React.ElementType;
variant: 'critical' | 'high' | 'medium' | 'low';
}
function SeverityCard({ title, count, icon: Icon, variant }: SeverityCardProps) {
const colors = {
critical: 'text-red-500 bg-red-50 dark:bg-red-950',
high: 'text-orange-500 bg-orange-50 dark:bg-orange-950',
medium: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-950',
low: 'text-blue-500 bg-blue-50 dark:bg-blue-950',
};
return (
<Card className={count > 0 ? colors[variant] : ''}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{count}</div>
<p className="text-xs text-muted-foreground">
unresolved events
</p>
</CardContent>
</Card>
);
}
EntityHistoryPanel
// components/EntityHistory/EntityHistoryPanel.tsx
import { useEntityHistory } from '../../hooks/useEntityHistory';
import { EntityHistoryTimeline } from './EntityHistoryTimeline';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { History, RefreshCw } from 'lucide-react';
interface EntityHistoryPanelProps {
entityType: string;
entityId: string;
title?: string;
}
export function EntityHistoryPanel({
entityType,
entityId,
title = 'Change History',
}: EntityHistoryPanelProps) {
const { history, isLoading, error, refetch } = useEntityHistory({
entityType,
entityId,
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<History className="h-5 w-5" />
{title}
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={refetch}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : error ? (
<div className="text-center text-destructive py-4">
{error}
</div>
) : history.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No changes recorded for this entity
</div>
) : (
<EntityHistoryTimeline history={history} />
)}
</CardContent>
</Card>
);
}
AccessLogStats
// components/AccessLogViewer/AccessLogStats.tsx
import { useMemo } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line, Bar } from 'react-chartjs-2';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { AccessStats, EndpointStats } from '../../types';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
Filler
);
interface AccessLogStatsProps {
stats: AccessStats[];
topEndpoints: EndpointStats[];
}
export function AccessLogStats({ stats, topEndpoints }: AccessLogStatsProps) {
const requestsChartData = useMemo(() => ({
labels: stats.map((s) => s.period),
datasets: [
{
label: 'Total Requests',
data: stats.map((s) => s.totalRequests),
borderColor: 'hsl(var(--primary))',
backgroundColor: 'hsl(var(--primary) / 0.1)',
fill: true,
tension: 0.4,
},
{
label: 'Errors',
data: stats.map((s) => s.errorCount),
borderColor: 'hsl(var(--destructive))',
backgroundColor: 'hsl(var(--destructive) / 0.1)',
fill: true,
tension: 0.4,
},
],
}), [stats]);
const responseTimeChartData = useMemo(() => ({
labels: stats.map((s) => s.period),
datasets: [
{
label: 'Avg Response Time (ms)',
data: stats.map((s) => Math.round(s.avgResponseTime)),
borderColor: 'hsl(var(--chart-2))',
backgroundColor: 'hsl(var(--chart-2) / 0.5)',
tension: 0.4,
},
],
}), [stats]);
const endpointsChartData = useMemo(() => ({
labels: topEndpoints.map((e) => `${e.method} ${e.path}`),
datasets: [
{
label: 'Request Count',
data: topEndpoints.map((e) => e.count),
backgroundColor: 'hsl(var(--primary) / 0.8)',
},
],
}), [topEndpoints]);
return (
<div className="grid gap-4 md:grid-cols-2">
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Request Volume</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<Line
data={requestsChartData}
options={{
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true },
},
}}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[250px]">
<Line
data={responseTimeChartData}
options={{
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true },
},
}}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top Endpoints</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[250px]">
<Bar
data={endpointsChartData}
options={{
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
},
}}
/>
</div>
</CardContent>
</Card>
</div>
);
}
SeverityBadge
// components/SecurityDashboard/SeverityBadge.tsx
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SecuritySeverity } from '../../types';
interface SeverityBadgeProps {
severity: SecuritySeverity;
className?: string;
}
const severityConfig: Record<SecuritySeverity, {
label: string;
className: string;
}> = {
critical: {
label: 'Critical',
className: 'bg-red-500 hover:bg-red-600 text-white',
},
high: {
label: 'High',
className: 'bg-orange-500 hover:bg-orange-600 text-white',
},
medium: {
label: 'Medium',
className: 'bg-yellow-500 hover:bg-yellow-600 text-black',
},
low: {
label: 'Low',
className: 'bg-blue-500 hover:bg-blue-600 text-white',
},
};
export function SeverityBadge({ severity, className }: SeverityBadgeProps) {
const config = severityConfig[severity];
return (
<Badge className={cn(config.className, className)}>
{config.label}
</Badge>
);
}
JsonViewer
// components/shared/JsonViewer.tsx
import { useState } from 'react';
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface JsonViewerProps {
data: any;
expanded?: boolean;
maxHeight?: string;
}
export function JsonViewer({
data,
expanded = false,
maxHeight = '400px',
}: JsonViewerProps) {
const [isExpanded, setIsExpanded] = useState(expanded);
const [copied, setCopied] = useState(false);
const formatted = JSON.stringify(data, null, 2);
const handleCopy = async () => {
await navigator.clipboard.writeText(formatted);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const preview = JSON.stringify(data).slice(0, 50) +
(JSON.stringify(data).length > 50 ? '...' : '');
return (
<div className="relative group">
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<code className="text-xs text-muted-foreground">
{isExpanded ? 'JSON Data' : preview}
</code>
</div>
{isExpanded && (
<div className="relative mt-2">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<pre
className={cn(
'p-4 rounded-md bg-muted text-xs overflow-auto',
'font-mono'
)}
style={{ maxHeight }}
>
{formatted}
</pre>
</div>
)}
</div>
);
}
Paginas
AuditLogsPage
// pages/AuditLogsPage.tsx
import { AuditLogViewer } from '../components/AuditLogViewer/AuditLogViewer';
import { ExportButton } from '../components/shared/ExportButton';
import { useAuditLogsStore } from '../stores/audit-logs.store';
import { PageHeader } from '@/components/layout/PageHeader';
export function AuditLogsPage() {
const { filters, isExporting, exportLogs } = useAuditLogsStore();
return (
<div className="container py-6 space-y-6">
<PageHeader
title="Audit Logs"
description="View and search entity changes across the system"
actions={
<ExportButton
onExport={(format) => exportLogs(format)}
isExporting={isExporting}
/>
}
/>
<AuditLogViewer />
</div>
);
}
SecurityEventsPage
// pages/SecurityEventsPage.tsx
import { SecurityDashboard } from '../components/SecurityDashboard/SecurityDashboard';
import { useSecurityAlerts } from '../hooks/useSecurityAlerts';
import { PageHeader } from '@/components/layout/PageHeader';
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
export function SecurityEventsPage() {
const { hasUnresolvedCritical, totalUnresolved } = useSecurityAlerts();
return (
<div className="container py-6 space-y-6">
<PageHeader
title={
<span className="flex items-center gap-2">
Security Events
{hasUnresolvedCritical && (
<Badge variant="destructive" className="animate-pulse">
<AlertTriangle className="h-3 w-3 mr-1" />
Critical
</Badge>
)}
</span>
}
description={
totalUnresolved > 0
? `${totalUnresolved} unresolved security events require attention`
: 'All security events have been resolved'
}
/>
<SecurityDashboard />
</div>
);
}
Rutas
// routes.tsx
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
const AccessLogsPage = lazy(() => import('./pages/AccessLogsPage'));
const SecurityEventsPage = lazy(() => import('./pages/SecurityEventsPage'));
const ScheduledReportsPage = lazy(() => import('./pages/ScheduledReportsPage'));
export const auditRoutes: RouteObject[] = [
{
path: 'audit',
children: [
{
path: 'logs',
element: <AuditLogsPage />,
},
{
path: 'access',
element: <AccessLogsPage />,
},
{
path: 'security',
element: <SecurityEventsPage />,
},
{
path: 'reports',
element: <ScheduledReportsPage />,
},
],
},
];
Wireframes
Audit Logs Viewer
+------------------------------------------------------------------+
| Audit Logs [Export v] |
| View and search entity changes across the system |
+------------------------------------------------------------------+
| Filters: |
| [Entity Type v] [Action v] [User v] [Date From] [Date To] [Clear] |
+------------------------------------------------------------------+
| Timestamp | Action | Entity | User | IP |
|--------------------|---------|--------------------|---------|-----|
| 2025-12-05 14:32 | create | Contact: John Doe | Admin | ... |
| 2025-12-05 14:30 | update | User: jane@... | Jane | ... |
| 2025-12-05 14:28 | delete | Product: Widget | Admin | ... |
| 2025-12-05 14:25 | update | Setting: timezone | System | ... |
+------------------------------------------------------------------+
| < 1 2 3 ... 10 > |
+------------------------------------------------------------------+
+-- Detail Sheet (on row click) ---+
| Audit Log Detail |
|----------------------------------|
| Action: UPDATE |
| Entity: Contact |
| Entity ID: abc-123... |
| User: admin@company.com |
| Timestamp: 2025-12-05 14:30:22 |
| IP: 192.168.1.100 |
| |
| Changes: |
| +------------------------------+ |
| | email (changed) | |
| | Before: old@email.com | |
| | After: new@email.com | |
| +------------------------------+ |
| | phone (added) | |
| | Before: (empty) | |
| | After: +1-555-1234 | |
| +------------------------------+ |
+----------------------------------+
Security Events Dashboard
+------------------------------------------------------------------+
| Security Events [! Critical] |
| 5 unresolved security events require attention |
+------------------------------------------------------------------+
| +------------+ +------------+ +------------+ +------------+ |
| | CRITICAL | | HIGH | | MEDIUM | | LOW | |
| | 2 | | 1 | | 2 | | 0 | |
| | unresolved | | unresolved | | unresolved | | unresolved | |
| +------------+ +------------+ +------------+ +------------+ |
+------------------------------------------------------------------+
| [Unresolved (5)] [All Events] |
+------------------------------------------------------------------+
| |
| +------------------------+ +------------------------+ |
| | [!] CRITICAL | | [!] CRITICAL | |
| | Brute Force Detected | | Session Hijack Attempt | |
| | user@company.com | | admin@company.com | |
| | 192.168.1.50 | | Unknown Location | |
| | 2 minutes ago | | 5 minutes ago | |
| +------------------------+ +------------------------+ |
| |
| +------------------------+ |
| | [/\] HIGH | |
| | New Admin Created | |
| | By: admin@company.com | |
| | 10 minutes ago | |
| +------------------------+ |
+------------------------------------------------------------------+
+-- Event Detail Sheet -------------+
| Security Event |
|-----------------------------------|
| [!] CRITICAL |
| Brute Force Detected |
| |
| 5 failed login attempts in 60s |
| Target: user@company.com |
| Source IP: 192.168.1.50 |
| Country: Unknown |
| Time: 2025-12-05 14:30:22 |
| |
| Metadata: |
| { |
| "attempts": 5, |
| "threshold": 5, |
| "window": "60s" |
| } |
| |
| [Resolve Event] |
| +-------------------------------+ |
| | Resolution Notes: | |
| | ____________________________ | |
| | IP blocked and user notified | |
| | ____________________________ | |
| +-------------------------------+ |
+-----------------------------------+
Access Logs Statistics
+------------------------------------------------------------------+
| Access Logs |
| API request history and performance metrics |
+------------------------------------------------------------------+
| [Stats] [Raw Logs] [Last 24h v] [Hourly v] |
+------------------------------------------------------------------+
| |
| Request Volume |
| +--------------------------------------------------------------+ |
| | /\ | |
| | / \ /\ ___Requests | |
| | / \ / \ / | |
| | / \/ \__/ ___Errors | |
| +--------------------------------------------------------------+ |
| | 00:00 04:00 08:00 12:00 16:00 20:00 24:00 | |
| |
+------------------------------------------------------------------+
| Response Time | Top Endpoints |
| +----------------------------+ | +----------------------------+ |
| | ___ | | | GET /api/users 1.2k | |
| | / \ | | | POST /api/auth 890 | |
| | / \__/\ | | | GET /api/contacts 756 | |
| | / \ | | | PUT /api/settings 432 | |
| +----------------------------+ | | DELETE /api/items 210 | |
| Avg: 45ms Max: 320ms | +----------------------------+ |
+------------------------------------------------------------------+
Entity History Panel (Reusable)
+----------------------------------------+
| Change History [R] |
+----------------------------------------+
| |
| +-- 2025-12-05 14:30 ----------------+ |
| | UPDATE by admin@company.com | |
| | Changed: email, phone | |
| | +--------------------------------+ | |
| | | email: old@... -> new@... | | |
| | | phone: (empty) -> +1-555-1234 | | |
| | +--------------------------------+ | |
| +------------------------------------+ |
| |
| +-- 2025-12-05 10:15 ----------------+ |
| | UPDATE by jane@company.com | |
| | Changed: name | |
| | +--------------------------------+ | |
| | | name: John -> John Doe | | |
| | +--------------------------------+ | |
| +------------------------------------+ |
| |
| +-- 2025-12-04 09:00 ----------------+ |
| | CREATE by admin@company.com | |
| | Initial creation | |
| +------------------------------------+ |
| |
+----------------------------------------+
Dependencias Frontend
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.0",
"@tanstack/react-query": "^5.0.0",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.3.0",
"tailwindcss": "^3.3.0"
}
}
Permisos de UI
| Componente |
Permiso Requerido |
| AuditLogsPage |
audit.logs.read |
| ExportButton (audit) |
audit.logs.export |
| AccessLogsPage |
audit.access.read |
| SecurityEventsPage |
audit.security.read |
| SecurityEventDetail (resolve) |
audit.security.manage |
| ScheduledReportsPage |
audit.reports.read |
| ScheduledReportForm |
audit.reports.manage |
Historial
| Version |
Fecha |
Autor |
Cambios |
| 1.0 |
2025-12-05 |
Requirements-Analyst |
Creacion inicial |