diff --git a/backend/src/middlewares/errorHandler.js b/backend/src/middlewares/errorHandler.js index cff0805..3afb8c1 100644 --- a/backend/src/middlewares/errorHandler.js +++ b/backend/src/middlewares/errorHandler.js @@ -1,3 +1,4 @@ +const auditService = require('../modules/audit/audit.service'); const { sendError } = require('../utils/apiResponse'); const errorHandler = (err, req, res, next) => { @@ -14,6 +15,23 @@ const errorHandler = (err, req, res, next) => { ? (err.message || 'Request error') : (statusCode >= 500 ? 'Internal server error' : err.message || 'Request error'); + if (req.path !== '/health' && req.path !== '/api/health') { + auditService.logEvent({ + type: 'PROCESS_ERROR', + userId: req.user?.id || null, + email: req.user?.email || req.body?.email || null, + ip: req.ip?.replace?.('::ffff:', '') || null, + userAgent: req.headers['user-agent'] || null, + metadata: { + method: req.method, + path: req.originalUrl || req.path, + statusCode, + message: err.message || null, + code: err.code || null, + }, + }).catch(() => {}); + } + if (err.code) { return res.status(statusCode).json({ success: false, diff --git a/backend/src/modules/audit/audit.controller.js b/backend/src/modules/audit/audit.controller.js new file mode 100644 index 0000000..e4dd8fd --- /dev/null +++ b/backend/src/modules/audit/audit.controller.js @@ -0,0 +1,43 @@ +const auditService = require('./audit.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const listMine = async (req, res, next) => { + try { + const result = await auditService.getEvents({ + userId: req.user.id, + role: req.user.role, + page: req.query.page, + limit: req.query.limit, + type: req.query.type, + }); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const purgeMine = async (req, res, next) => { + try { + if (!req.body?.certPassword) { + const error = new Error('Debes ingresar la contraseña de la firma registrada.'); + error.statusCode = 400; + throw error; + } + const result = await auditService.purgeUserEvents({ + actorUserId: req.user.id, + actorEmail: req.user.email, + actorRole: req.user.role, + certPassword: req.body.certPassword, + ip: req.ip?.replace('::ffff:', '') || null, + userAgent: req.headers['user-agent'] || null, + }); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +module.exports = { + listMine, + purgeMine, +}; diff --git a/backend/src/modules/audit/audit.repository.js b/backend/src/modules/audit/audit.repository.js index 5df660e..f626133 100644 --- a/backend/src/modules/audit/audit.repository.js +++ b/backend/src/modules/audit/audit.repository.js @@ -30,15 +30,39 @@ const saveEvent = async (event) => { } }; -const getEvents = async ({ type, userId, limit = 100, offset = 0 } = {}) => { +const getEvents = async ({ type, userId, limit = 100, offset = 0, types = [] } = {}) => { let sql = 'SELECT * FROM audit_logs WHERE 1=1'; + let countSql = 'SELECT COUNT(*) AS total FROM audit_logs WHERE 1=1'; const params = []; + const countParams = []; if (type) { sql += ' AND type = ?'; params.push(type); } - if (userId) { sql += ' AND user_id = ?'; params.push(userId); } + if (type) { countSql += ' AND type = ?'; countParams.push(type); } + if (types.length > 0) { + const placeholders = types.map(() => '?').join(', '); + sql += ` AND type IN (${placeholders})`; + countSql += ` AND type IN (${placeholders})`; + params.push(...types); + countParams.push(...types); + } + if (userId) { + sql += ' AND user_id = ?'; + countSql += ' AND user_id = ?'; + params.push(userId); + countParams.push(userId); + } sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); const [rows] = await db.execute(sql, params); - return rows; + const [countRows] = await db.execute(countSql, countParams); + return { + rows, + total: countRows[0]?.total || 0, + }; }; -module.exports = { saveEvent, getEvents }; +const deleteEventsByUserId = async (userId) => { + const [result] = await db.execute('DELETE FROM audit_logs WHERE user_id = ?', [userId]); + return result.affectedRows || 0; +}; + +module.exports = { saveEvent, getEvents, deleteEventsByUserId }; diff --git a/backend/src/modules/audit/audit.routes.js b/backend/src/modules/audit/audit.routes.js new file mode 100644 index 0000000..977d9e7 --- /dev/null +++ b/backend/src/modules/audit/audit.routes.js @@ -0,0 +1,10 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const auditController = require('./audit.controller'); + +const router = express.Router(); + +router.get('/mine', authenticate, auditController.listMine); +router.post('/mine/purge', authenticate, auditController.purgeMine); + +module.exports = router; diff --git a/backend/src/modules/audit/audit.service.js b/backend/src/modules/audit/audit.service.js index 4344d7b..77b741e 100644 --- a/backend/src/modules/audit/audit.service.js +++ b/backend/src/modules/audit/audit.service.js @@ -1,11 +1,74 @@ const auditRepository = require('./audit.repository'); +const certificatesService = require('../certificates/certificates.service'); const logEvent = async ({ type, userId = null, email = null, ip = null, userAgent = null, metadata = null } = {}) => { await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata }); }; -const getEvents = async (filters) => { - return auditRepository.getEvents(filters); +const ADMIN_VISIBLE_TYPES = ['AUDIT_LOGS_PURGED']; + +const getEvents = async (filters = {}) => { + const limit = Math.min(Math.max(Number(filters.limit) || 15, 1), 50); + const page = Math.max(Number(filters.page) || 1, 1); + const offset = (page - 1) * limit; + + const types = filters.role === 'admin' ? ADMIN_VISIBLE_TYPES : []; + + const result = await auditRepository.getEvents({ + type: filters.type || null, + userId: filters.role === 'admin' ? null : (filters.userId || null), + limit, + offset, + types, + }); + + return { + rows: result.rows.map((row) => ({ + ...row, + metadata: row.metadata ? JSON.parse(row.metadata) : null, + })), + pagination: { + page, + limit, + total: result.total, + totalPages: Math.max(1, Math.ceil(result.total / limit)), + }, + }; }; -module.exports = { logEvent, getEvents }; +const purgeUserEvents = async ({ actorUserId, actorEmail, actorRole, certPassword, ip, userAgent }) => { + if (actorRole === 'admin') { + const error = new Error('La cuenta administradora no puede eliminar logs.'); + error.statusCode = 403; + throw error; + } + + const activeCertificate = await certificatesService.getLatestActiveForUser(actorUserId); + if (!activeCertificate) { + const error = new Error('No existe un certificado activo asociado a esta cuenta.'); + error.statusCode = 404; + throw error; + } + + await certificatesService.loadForSigning(activeCertificate.id, actorUserId, certPassword); + + const deletedCount = await auditRepository.deleteEventsByUserId(actorUserId); + + await auditRepository.saveEvent({ + type: 'AUDIT_LOGS_PURGED', + userId: null, + email: actorEmail, + ip, + userAgent, + metadata: { + purgedUserId: actorUserId, + purgedEmail: actorEmail, + deletedCount, + certificateId: activeCertificate.id, + }, + }); + + return { deletedCount }; +}; + +module.exports = { logEvent, getEvents, purgeUserEvents }; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index feb1adf..e5500d4 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -1,5 +1,6 @@ const express = require('express'); const authRoutes = require('../modules/auth/auth.routes'); +const auditRoutes = require('../modules/audit/audit.routes'); const certificatesRoutes = require('../modules/certificates/certificates.routes'); const downloadsRoutes = require('../modules/downloads/downloads.routes'); const documentsRoutes = require('../modules/documents/documents.routes'); @@ -11,6 +12,7 @@ const healthRoutes = require('../modules/health/health.routes'); const router = express.Router(); router.use('/auth', authRoutes); +router.use('/audit', auditRoutes); router.use('/certificates', certificatesRoutes); router.use('/downloads', downloadsRoutes); router.use('/documents', documentsRoutes); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c7635b6..e4d67fd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import SignatureStylePage from './pages/signature-style/SignatureStylePage'; import SignaturesPage from './pages/signatures/SignaturesPage'; import VerifySignaturePage from './pages/verify/VerifySignaturePage'; import LegalPage from './pages/legal/LegalPage'; +import AuditPage from './pages/audit/AuditPage'; export default function App() { return ( @@ -53,6 +54,10 @@ export default function App() { path="/legal" element={} /> + } + /> } /> diff --git a/frontend/src/api/audit.js b/frontend/src/api/audit.js new file mode 100644 index 0000000..71c8bcd --- /dev/null +++ b/frontend/src/api/audit.js @@ -0,0 +1,10 @@ +import client from './client'; + +export const listMyAuditEvents = ({ page = 1, limit = 15, type } = {}) => { + const params = { page, limit }; + if (type) params.type = type; + return client.get('/audit/mine', { params }); +}; + +export const purgeMyAuditEvents = (certPassword) => + client.post('/audit/mine/purge', { certPassword }); diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index e8e9600..10c5843 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -18,6 +18,7 @@ const navSections = [ label: 'Sistema', items: [ { to: '/signature-style', icon: Settings, label: 'Configuración' }, + { to: '/audit', icon: ScrollText, label: 'Auditoría' }, ], }, ]; @@ -29,6 +30,7 @@ const topbarTitles = { '/signature-style': 'Configuración', '/signatures': 'Firmar ahora', '/legal': 'Legal', + '/audit': 'Auditoría', }; export default function Layout({ children }) { @@ -113,12 +115,8 @@ export default function Layout({ children }) { ))} ))} -
+
Extra
- - - Auditoría - { + if (!value) return '—'; + return new Date(value).toLocaleString('es-EC'); +}; + +const renderMetadata = (metadata) => { + if (!metadata) return '—'; + if (metadata.documents?.length) { + return metadata.documents.map((item) => item.title || item.slug).join(', '); + } + if (metadata.path || metadata.method || metadata.message) { + return [metadata.method, metadata.path, metadata.message].filter(Boolean).join(' · '); + } + if (metadata.alias || metadata.subject) { + return [metadata.alias, metadata.subject].filter(Boolean).join(' · '); + } + return Object.entries(metadata) + .slice(0, 3) + .map(([key, value]) => `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`) + .join(' · '); +}; + +export default function AuditPage() { + const { user } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(15); + const [pagination, setPagination] = useState({ page: 1, totalPages: 1, total: 0 }); + const [showPurgeModal, setShowPurgeModal] = useState(false); + const [certPassword, setCertPassword] = useState(''); + const [purging, setPurging] = useState(false); + + const loadEvents = () => { + setLoading(true); + return listMyAuditEvents({ page, limit }) + .then(({ data }) => { + setItems(data.data.rows || []); + setPagination(data.data.pagination || { page: 1, totalPages: 1, total: 0 }); + }) + .catch(() => toast.error('No se pudieron cargar tus logs')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + loadEvents(); + }, [page, limit]); + + const handlePurgeLogs = async (event) => { + event.preventDefault(); + setPurging(true); + try { + const { data } = await purgeMyAuditEvents(certPassword); + toast.success(`Se eliminaron ${data.data.deletedCount} logs de tu cuenta.`); + setCertPassword(''); + setShowPurgeModal(false); + setPage(1); + await loadEvents(); + } catch (error) { + toast.error(error.response?.data?.error || 'No se pudieron eliminar tus logs.'); + } finally { + setPurging(false); + } + }; + + return ( +
+ {showPurgeModal && ( +
setShowPurgeModal(false)}> +
event.stopPropagation()}> +
+
+ + Eliminar mis logs +
+ +
+

+ Para eliminar tus logs debes validar la contraseña de tu firma registrada. Esta acción dejará un rastro mínimo visible solo para administración. +

+
+
+ + setCertPassword(event.target.value)} + required + /> +
+
+ + +
+
+
+
+ )} + +
+
+

Auditoría

+

+ {user?.role === 'admin' + ? 'Aquí se conservan únicamente los rastros administrativos de purga de logs.' + : 'Tus eventos del sistema, del más nuevo al más antiguo, con IP, fecha y detalle técnico relevante.'} +

+
+
+ +
+
+ Total de eventos: + {pagination.total} +
+
+ + +
+ {user?.role !== 'admin' && ( + + )} +
+ +
+
+ Evento + Fecha + IP + Detalle +
+ + {loading ? ( +
+ ) : items.length === 0 ? ( +
+ +

Aún no hay eventos de auditoría para mostrar.

+
+ ) : ( + items.map((item) => { + const Icon = EVENT_ICONS[item.type] || CheckCircle2; + return ( +
+
+
+ +
+
+
{EVENT_LABELS[item.type] || item.type}
+
{item.type}
+
+
+ {formatDateTime(item.created_at)} + {item.ip || '—'} + {renderMetadata(item.metadata)} +
+ ); + }) + )} +
+ +
+ + Página {pagination.page} de {pagination.totalPages} + +
+
+ ); +}