implementar visualizacion de logs y seteo de usuario administrador

This commit is contained in:
2026-04-07 16:11:49 -05:00
parent 9a8e70c943
commit 605ea94041
11 changed files with 479 additions and 12 deletions
+5
View File
@@ -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={<PrivateRoute><Layout><LegalPage /></Layout></PrivateRoute>}
/>
<Route
path="/audit"
element={<PrivateRoute><Layout><AuditPage /></Layout></PrivateRoute>}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
+10
View File
@@ -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 });
+3 -5
View File
@@ -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 }) {
))}
</div>
))}
<div className="sidebar-group sidebar-group--disabled">
<div className="sidebar-group">
<div className="nav-group-label">Extra</div>
<span className="nav-item nav-item--static">
<ScrollText size={15} />
<span>Auditoría</span>
</span>
<Link
to="/legal"
className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
+57
View File
@@ -610,6 +610,63 @@ select {
background: var(--gray-50);
}
.audit-toolbar,
.audit-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.audit-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-700);
}
.audit-toolbar-group select {
border: 1px solid var(--gray-300);
border-radius: 8px;
background: #fff;
padding: 6px 10px;
}
.audit-table-header,
.audit-table-row {
grid-template-columns: minmax(220px, 1.2fr) 180px 120px minmax(260px, 1.5fr);
}
.audit-event-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.audit-event-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--c-info-bg);
color: var(--c-primary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.audit-event-icon.is-error {
background: var(--c-danger-bg);
color: var(--c-danger);
}
.audit-detail-cell {
color: var(--gray-600);
line-height: 1.5;
}
/* ─── Buttons ────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react';
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, FileSignature, LogIn, ShieldAlert, Trash2, UserPlus2, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { listMyAuditEvents, purgeMyAuditEvents } from '../../api/audit';
import { useAuth } from '../../contexts/AuthContext';
const PAGE_SIZE_OPTIONS = [10, 15, 30, 50];
const EVENT_LABELS = {
USER_REGISTERED: 'Registro inicial',
LOGIN_SUCCESS: 'Acceso correcto',
LOGIN_FAILED: 'Error de acceso',
VERIFICATION_EMAIL_RESENT: 'Reenvío de validación',
PASSWORD_CHANGE_REQUESTED: 'Solicitud de cambio',
PASSWORD_CHANGED: 'Cambio correcto',
ACCOUNT_UNLOCK_PASSWORD_CHANGE_REQUESTED: 'Desbloqueo solicitado',
LEGAL_PACKAGE_SIGNED: 'Documentos legales firmados',
DOCUMENT_UPLOAD: 'Documento cargado',
CERTIFICATE_UPLOAD: 'Certificado cargado',
CERTIFICATE_DELETE: 'Certificado eliminado',
PROCESS_ERROR: 'Error de proceso',
};
const EVENT_ICONS = {
USER_REGISTERED: UserPlus2,
LOGIN_SUCCESS: LogIn,
LOGIN_FAILED: ShieldAlert,
PROCESS_ERROR: AlertCircle,
LEGAL_PACKAGE_SIGNED: FileSignature,
};
const formatDateTime = (value) => {
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 (
<div className="page">
{showPurgeModal && (
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">
<Trash2 size={18} />
<span>Eliminar mis logs</span>
</div>
<button type="button" className="btn-icon" onClick={() => setShowPurgeModal(false)}>
<X size={18} />
</button>
</div>
<p className="modal-desc">
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.
</p>
<form onSubmit={handlePurgeLogs} className="auth-form">
<div className="field">
<label>Contraseña de la firma</label>
<input
type="password"
value={certPassword}
onChange={(event) => setCertPassword(event.target.value)}
required
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowPurgeModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={purging}>
{purging ? 'Eliminando...' : 'Eliminar logs'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Auditoría</h2>
<p>
{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.'}
</p>
</div>
</div>
<div className="form-card audit-toolbar">
<div className="audit-toolbar-group">
<strong>Total de eventos:</strong>
<span>{pagination.total}</span>
</div>
<div className="audit-toolbar-group">
<label htmlFor="audit-limit">Mostrar</label>
<select
id="audit-limit"
value={limit}
onChange={(event) => {
setPage(1);
setLimit(Number(event.target.value));
}}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
{user?.role !== 'admin' && (
<button type="button" className="btn btn-secondary" onClick={() => setShowPurgeModal(true)}>
<Trash2 size={14} />
Eliminar mis logs
</button>
)}
</div>
<div className="panel">
<div className="table-header audit-table-header">
<span className="th">Evento</span>
<span className="th">Fecha</span>
<span className="th">IP</span>
<span className="th">Detalle</span>
</div>
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : items.length === 0 ? (
<div className="empty-state">
<CheckCircle2 size={44} />
<p>Aún no hay eventos de auditoría para mostrar.</p>
</div>
) : (
items.map((item) => {
const Icon = EVENT_ICONS[item.type] || CheckCircle2;
return (
<div key={item.id} className="table-row audit-table-row">
<div className="audit-event-cell">
<div className={`audit-event-icon ${item.type === 'PROCESS_ERROR' || item.type === 'LOGIN_FAILED' ? 'is-error' : ''}`}>
<Icon size={14} />
</div>
<div>
<div className="doc-n">{EVENT_LABELS[item.type] || item.type}</div>
<div className="doc-m">{item.type}</div>
</div>
</div>
<span className="td">{formatDateTime(item.created_at)}</span>
<span className="td">{item.ip || '—'}</span>
<span className="td audit-detail-cell">{renderMetadata(item.metadata)}</span>
</div>
);
})
)}
</div>
<div className="form-card audit-pagination">
<button
type="button"
className="btn btn-secondary"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
<ChevronLeft size={14} />
Anterior
</button>
<span>Página {pagination.page} de {pagination.totalPages}</span>
<button
type="button"
className="btn btn-secondary"
disabled={page >= pagination.totalPages}
onClick={() => setPage((current) => Math.min(pagination.totalPages, current + 1))}
>
Siguiente
<ChevronRight size={14} />
</button>
</div>
</div>
);
}