erp-core/docs/02-fase-core-business/MGN-007-audit/especificaciones/ET-AUDIT-frontend.md

51 KiB

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