roadmap #23
200
client/src/components/AuditLog.jsx
Normal file
200
client/src/components/AuditLog.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const ACTION_COLORS = {
|
||||||
|
employee_created: '#667eea',
|
||||||
|
employee_edited: '#9b8af8',
|
||||||
|
employee_merged: '#f0a500',
|
||||||
|
violation_created: '#28a745',
|
||||||
|
violation_amended: '#4db6ac',
|
||||||
|
violation_negated: '#ffc107',
|
||||||
|
violation_restored:'#17a2b8',
|
||||||
|
violation_deleted: '#dc3545',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
employee_created: 'Employee Created',
|
||||||
|
employee_edited: 'Employee Edited',
|
||||||
|
employee_merged: 'Employee Merged',
|
||||||
|
violation_created: 'Violation Logged',
|
||||||
|
violation_amended: 'Violation Amended',
|
||||||
|
violation_negated: 'Violation Negated',
|
||||||
|
violation_restored:'Violation Restored',
|
||||||
|
violation_deleted: 'Violation Deleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_LABELS = {
|
||||||
|
employee: 'Employee',
|
||||||
|
violation: 'Violation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||||
|
zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw',
|
||||||
|
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
||||||
|
padding: '22px 26px', position: 'sticky', top: 0, zIndex: 10,
|
||||||
|
borderBottom: '1px solid #222',
|
||||||
|
},
|
||||||
|
headerRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
title: { fontSize: '17px', fontWeight: 700 },
|
||||||
|
subtitle: { fontSize: '12px', color: '#9ca0b8', marginTop: '3px' },
|
||||||
|
closeBtn: {
|
||||||
|
background: 'none', border: 'none', color: 'white', fontSize: '22px',
|
||||||
|
cursor: 'pointer', lineHeight: 1,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
padding: '14px 26px', borderBottom: '1px solid #1c1d29',
|
||||||
|
display: 'flex', gap: '10px', flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||||
|
color: '#f8f9fa', padding: '7px 12px', fontSize: '12px', outline: 'none',
|
||||||
|
},
|
||||||
|
body: { padding: '16px 26px', flex: 1 },
|
||||||
|
entry: {
|
||||||
|
borderBottom: '1px solid #1c1d29', padding: '12px 0',
|
||||||
|
display: 'flex', gap: '12px', alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
dot: (action) => ({
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%', marginTop: '5px', flexShrink: 0,
|
||||||
|
background: ACTION_COLORS[action] || '#555',
|
||||||
|
}),
|
||||||
|
entryMain: { flex: 1, minWidth: 0 },
|
||||||
|
actionBadge: (action) => ({
|
||||||
|
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
|
||||||
|
fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', marginRight: '6px',
|
||||||
|
background: (ACTION_COLORS[action] || '#555') + '22',
|
||||||
|
color: ACTION_COLORS[action] || '#aaa',
|
||||||
|
border: `1px solid ${(ACTION_COLORS[action] || '#555')}44`,
|
||||||
|
}),
|
||||||
|
entityRef: { fontSize: '11px', color: '#9ca0b8' },
|
||||||
|
details: { fontSize: '11px', color: '#667', marginTop: '4px', fontFamily: 'monospace', wordBreak: 'break-all' },
|
||||||
|
meta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
|
||||||
|
empty: { textAlign: 'center', color: '#555a7a', padding: '60px 0', fontSize: '13px' },
|
||||||
|
loadMore: {
|
||||||
|
width: '100%', background: 'none', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||||
|
color: '#9ca0b8', padding: '10px', cursor: 'pointer', fontSize: '12px', marginTop: '16px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDt(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('en-US', {
|
||||||
|
timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(detailsStr) {
|
||||||
|
if (!detailsStr) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(detailsStr);
|
||||||
|
return JSON.stringify(obj, null, 0)
|
||||||
|
.replace(/^\{/, '').replace(/\}$/, '').replace(/","/g, ' ');
|
||||||
|
} catch {
|
||||||
|
return detailsStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLog({ onClose }) {
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [filterType, setFilterType] = useState('');
|
||||||
|
const [filterAction, setFilterAction] = useState('');
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const load = useCallback((reset = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
const o = reset ? 0 : offset;
|
||||||
|
const params = { limit: LIMIT, offset: o };
|
||||||
|
if (filterType) params.entity_type = filterType;
|
||||||
|
if (filterAction) params.action = filterAction; // future: server-side action filter
|
||||||
|
axios.get('/api/audit', { params })
|
||||||
|
.then(r => {
|
||||||
|
const data = r.data;
|
||||||
|
// Client-side action filter (cheap enough at this scale)
|
||||||
|
const filtered = filterAction ? data.filter(e => e.action === filterAction) : data;
|
||||||
|
setEntries(prev => reset ? filtered : [...prev, ...filtered]);
|
||||||
|
setHasMore(data.length === LIMIT);
|
||||||
|
setOffset(o + LIMIT);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [offset, filterType, filterAction]);
|
||||||
|
|
||||||
|
useEffect(() => { load(true); }, [filterType, filterAction]); // eslint-disable-line
|
||||||
|
|
||||||
|
const handleOverlay = e => { if (e.target === e.currentTarget) onClose(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.overlay} onClick={handleOverlay}>
|
||||||
|
<div style={s.panel} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={s.header}>
|
||||||
|
<div style={s.headerRow}>
|
||||||
|
<div>
|
||||||
|
<div style={s.title}>Audit Log</div>
|
||||||
|
<div style={s.subtitle}>All system write actions — append-only</div>
|
||||||
|
</div>
|
||||||
|
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.filters}>
|
||||||
|
<select style={s.select} value={filterType} onChange={e => { setFilterType(e.target.value); setOffset(0); }}>
|
||||||
|
<option value="">All entity types</option>
|
||||||
|
{Object.entries(ENTITY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
<select style={s.select} value={filterAction} onChange={e => { setFilterAction(e.target.value); setOffset(0); }}>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{Object.entries(ACTION_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.body}>
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div style={s.empty}>Loading…</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div style={s.empty}>No audit entries found.</div>
|
||||||
|
) : (
|
||||||
|
entries.map(e => (
|
||||||
|
<div key={e.id} style={s.entry}>
|
||||||
|
<div style={s.dot(e.action)} />
|
||||||
|
<div style={s.entryMain}>
|
||||||
|
<div>
|
||||||
|
<span style={s.actionBadge(e.action)}>
|
||||||
|
{ACTION_LABELS[e.action] || e.action}
|
||||||
|
</span>
|
||||||
|
<span style={s.entityRef}>
|
||||||
|
{ENTITY_LABELS[e.entity_type] || e.entity_type}
|
||||||
|
{e.entity_id ? ` #${e.entity_id}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{e.details && (
|
||||||
|
<div style={s.details}>{renderDetails(e.details)}</div>
|
||||||
|
)}
|
||||||
|
<div style={s.meta}>
|
||||||
|
{e.performed_by ? `by ${e.performed_by} · ` : ''}{fmtDt(e.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button style={s.loadMore} onClick={() => load(false)}>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user