audit logic

This commit is contained in:
2026-01-26 20:39:23 -06:00
parent 196f7a53b3
commit 6b9f6810ab
10 changed files with 5033 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import ProjectsPage from "./pages/projects/ProjectsPage";
import UsersPage from "./pages/UsersPage";
import RolesPage from "./pages/RolesPage";
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
import AuditoriaPage from "./pages/AuditoriaPage";
import ProfileModal from "./components/layout/common/ProfileModal";
import { updateMyProfile } from "./api/me";
@@ -37,6 +38,7 @@ export type Page =
| "meters"
| "concentrators"
| "consumption"
| "auditoria"
| "users"
| "roles";
@@ -175,6 +177,8 @@ export default function App() {
return <ConcentratorsPage />;
case "consumption":
return <ConsumptionPage />;
case "auditoria":
return <AuditoriaPage />;
case "users":
return <UsersPage />;
case "roles":

151
src/api/audit.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Audit API Client
* Functions to interact with audit log endpoints
*/
import { apiClient } from './client';
export type AuditAction =
| 'CREATE'
| 'UPDATE'
| 'DELETE'
| 'LOGIN'
| 'LOGOUT'
| 'READ'
| 'EXPORT'
| 'BULK_UPLOAD'
| 'STATUS_CHANGE'
| 'PERMISSION_CHANGE';
export interface AuditLog {
id: string;
user_id: string | null;
user_email: string;
user_name: string;
action: AuditAction;
table_name: string;
record_id: string | null;
old_values: Record<string, any> | null;
new_values: Record<string, any> | null;
description: string | null;
ip_address: string | null;
user_agent: string | null;
success: boolean;
error_message: string | null;
created_at: string;
}
export interface AuditLogFilters {
userId?: string;
action?: AuditAction;
tableName?: string;
recordId?: string;
startDate?: string;
endDate?: string;
success?: boolean;
page?: number;
limit?: number;
}
export interface AuditLogListResponse {
success: boolean;
message: string;
data: AuditLog[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface AuditLogResponse {
success: boolean;
message: string;
data: AuditLog;
}
export interface AuditStatistic {
date: string;
action: AuditAction;
table_name: string;
action_count: number;
unique_users: number;
successful_actions: number;
failed_actions: number;
}
export interface AuditStatisticsResponse {
success: boolean;
message: string;
data: AuditStatistic[];
}
/**
* Get all audit logs with filters (admin only)
*/
export async function getAuditLogs(
filters?: AuditLogFilters
): Promise<AuditLogListResponse> {
const params = new URLSearchParams();
if (filters?.userId) params.append('userId', filters.userId);
if (filters?.action) params.append('action', filters.action);
if (filters?.tableName) params.append('tableName', filters.tableName);
if (filters?.recordId) params.append('recordId', filters.recordId);
if (filters?.startDate) params.append('startDate', filters.startDate);
if (filters?.endDate) params.append('endDate', filters.endDate);
if (filters?.success !== undefined)
params.append('success', String(filters.success));
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
const queryString = params.toString();
const url = queryString ? `/api/audit-logs?${queryString}` : '/api/audit-logs';
return apiClient.get<AuditLogListResponse>(url);
}
/**
* Get a single audit log by ID (admin only)
*/
export async function getAuditLogById(id: string): Promise<AuditLogResponse> {
return apiClient.get<AuditLogResponse>(`/api/audit-logs/${id}`);
}
/**
* Get audit logs for a specific record (admin only)
*/
export async function getAuditLogsForRecord(
tableName: string,
recordId: string
): Promise<AuditLogListResponse> {
return apiClient.get<AuditLogListResponse>(
`/api/audit-logs/record/${tableName}/${recordId}`
);
}
/**
* Get audit statistics (admin only)
*/
export async function getAuditStatistics(
days: number = 30
): Promise<AuditStatisticsResponse> {
return apiClient.get<AuditStatisticsResponse>(
`/api/audit-logs/statistics?days=${days}`
);
}
/**
* Get current user's activity logs
*/
export async function getMyActivity(
page: number = 1,
limit: number = 50
): Promise<AuditLogListResponse> {
return apiClient.get<AuditLogListResponse>(
`/api/audit-logs/my-activity?page=${page}&limit=${limit}`
);
}

View File

@@ -114,6 +114,15 @@ export default function Sidebar({ setPage }: SidebarProps) {
Consumo
</button>
</li>
<li>
<button
onClick={() => setPage("auditoria")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Auditoría
</button>
</li>
</ul>
)}
</li>

460
src/pages/AuditoriaPage.tsx Normal file
View File

@@ -0,0 +1,460 @@
import { useState, useEffect } from "react";
import { Search, Filter, RefreshCw, Download, Eye } from "lucide-react";
import {
getAuditLogs,
type AuditLog,
type AuditAction,
} from "../api/audit";
export default function AuditoriaPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selectedAction, setSelectedAction] = useState<AuditAction | "">("");
const [selectedTable, setSelectedTable] = useState("");
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const limit = 50;
const actions: AuditAction[] = [
"CREATE",
"UPDATE",
"DELETE",
"LOGIN",
"LOGOUT",
"READ",
"EXPORT",
"BULK_UPLOAD",
"STATUS_CHANGE",
"PERMISSION_CHANGE",
];
useEffect(() => {
fetchAuditLogs();
}, [currentPage, selectedAction, selectedTable]);
const fetchAuditLogs = async () => {
try {
setLoading(true);
const filters: any = {
page: currentPage,
limit,
};
if (selectedAction) filters.action = selectedAction;
if (selectedTable) filters.tableName = selectedTable;
const response = await getAuditLogs(filters);
setLogs(response.data);
setTotal(response.pagination.total);
setTotalPages(response.pagination.totalPages);
} catch (error) {
console.error("Failed to fetch audit logs:", error);
setLogs([]);
} finally {
setLoading(false);
}
};
const handleRefresh = () => {
setCurrentPage(1);
fetchAuditLogs();
};
const handleClearFilters = () => {
setSelectedAction("");
setSelectedTable("");
setSearch("");
setCurrentPage(1);
};
const handleViewDetails = (log: AuditLog) => {
setSelectedLog(log);
setShowDetails(true);
};
const getActionColor = (action: AuditAction) => {
const colors: Record<AuditAction, string> = {
CREATE: "bg-green-100 text-green-800",
UPDATE: "bg-blue-100 text-blue-800",
DELETE: "bg-red-100 text-red-800",
LOGIN: "bg-purple-100 text-purple-800",
LOGOUT: "bg-gray-100 text-gray-800",
READ: "bg-cyan-100 text-cyan-800",
EXPORT: "bg-yellow-100 text-yellow-800",
BULK_UPLOAD: "bg-orange-100 text-orange-800",
STATUS_CHANGE: "bg-indigo-100 text-indigo-800",
PERMISSION_CHANGE: "bg-pink-100 text-pink-800",
};
return colors[action] || "bg-gray-100 text-gray-800";
};
const filteredLogs = logs.filter((log) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
log.user_email.toLowerCase().includes(searchLower) ||
log.user_name.toLowerCase().includes(searchLower) ||
log.table_name.toLowerCase().includes(searchLower) ||
log.description?.toLowerCase().includes(searchLower)
);
});
const uniqueTables = Array.from(new Set(logs.map((log) => log.table_name)));
return (
<div className="flex h-full">
{/* Sidebar */}
<aside className="w-64 border-r border-gray-200 bg-white p-4">
<h2 className="text-lg font-semibold mb-4">Filtros</h2>
{/* Action Filter */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Acción
</label>
<select
value={selectedAction}
onChange={(e) => setSelectedAction(e.target.value as AuditAction | "")}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Todas las acciones</option>
{actions.map((action) => (
<option key={action} value={action}>
{action}
</option>
))}
</select>
</div>
{/* Table Filter */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Tabla
</label>
<select
value={selectedTable}
onChange={(e) => setSelectedTable(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Todas las tablas</option>
{uniqueTables.map((table) => (
<option key={table} value={table}>
{table}
</option>
))}
</select>
</div>
{/* Clear Filters */}
<button
onClick={handleClearFilters}
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm"
>
Limpiar filtros
</button>
{/* Statistics */}
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-2">
Estadísticas
</h3>
<div className="text-sm text-gray-600">
<p>Total de registros: {total}</p>
<p>Página actual: {currentPage} de {totalPages}</p>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">
Auditoría del Sistema
</h1>
<div className="flex gap-2">
<button
onClick={handleRefresh}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar por usuario, email, tabla o descripción..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md"
/>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="text-center py-12 text-gray-500">
Cargando registros...
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No se encontraron registros de auditoría
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Fecha/Hora
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Usuario
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Acción
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tabla
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Descripción
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
{new Date(log.created_at).toLocaleString("es-MX")}
</td>
<td className="px-4 py-3 text-sm">
<div className="font-medium text-gray-900">
{log.user_name}
</div>
<div className="text-gray-500">{log.user_email}</div>
</td>
<td className="px-4 py-3 text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
log.action
)}`}
>
{log.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{log.table_name}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{log.description || "-"}
</td>
<td className="px-4 py-3 text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{log.success ? "Éxito" : "Fallo"}
</span>
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => handleViewDetails(log)}
className="text-blue-600 hover:text-blue-800"
>
<Eye className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<span className="px-4 py-2">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
)}
</div>
</main>
{/* Details Modal */}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">Detalles del Registro</h2>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
ID
</label>
<p className="text-sm text-gray-900 font-mono">
{selectedLog.id}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Fecha/Hora
</label>
<p className="text-sm text-gray-900">
{new Date(selectedLog.created_at).toLocaleString("es-MX")}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Usuario
</label>
<p className="text-sm text-gray-900">{selectedLog.user_name}</p>
<p className="text-xs text-gray-500">{selectedLog.user_email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Acción
</label>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
selectedLog.action
)}`}
>
{selectedLog.action}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Tabla
</label>
<p className="text-sm text-gray-900">{selectedLog.table_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Record ID
</label>
<p className="text-sm text-gray-900 font-mono">
{selectedLog.record_id || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
IP Address
</label>
<p className="text-sm text-gray-900">
{selectedLog.ip_address || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Estado
</label>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
selectedLog.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{selectedLog.success ? "Éxito" : "Fallo"}
</span>
</div>
</div>
{selectedLog.description && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Descripción
</label>
<p className="text-sm text-gray-900">{selectedLog.description}</p>
</div>
)}
{selectedLog.old_values && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Valores Anteriores
</label>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
{JSON.stringify(selectedLog.old_values, null, 2)}
</pre>
</div>
)}
{selectedLog.new_values && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Valores Nuevos
</label>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
{JSON.stringify(selectedLog.new_values, null, 2)}
</pre>
</div>
)}
{selectedLog.error_message && (
<div>
<label className="block text-sm font-medium text-red-700 mb-2">
Mensaje de Error
</label>
<p className="text-sm text-red-900 bg-red-50 p-3 rounded">
{selectedLog.error_message}
</p>
</div>
)}
</div>
<div className="p-6 border-t border-gray-200 flex justify-end">
<button
onClick={() => setShowDetails(false)}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md"
>
Cerrar
</button>
</div>
</div>
</div>
)}
</div>
);
}