feat: implement audit trail module
- Backend (FastAPI): - AuditLog and AuditAlert models with Alembic migration - AuditService with SHA-256 checksum for log integrity - AuditMiddleware for request metadata extraction (IP, user_agent) - Integrated audit logging into Task, Project, Blocker APIs - Query API with filtering, pagination, CSV export - Integrity verification endpoint - Sensitive operation alerts with acknowledgement - Frontend (React + Vite): - Admin AuditPage with filters and export - ResourceHistory component for change tracking - Audit service for API calls - Testing: - 15 tests covering service and API endpoints - OpenSpec: - add-audit-trail change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
149
frontend/src/components/ResourceHistory.tsx
Normal file
149
frontend/src/components/ResourceHistory.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { auditService, AuditLog } from '../services/audit'
|
||||
|
||||
interface ResourceHistoryProps {
|
||||
resourceType: string
|
||||
resourceId: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function ResourceHistory({ resourceType, resourceId, title = 'Change History' }: ResourceHistoryProps) {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [resourceType, resourceId])
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await auditService.getResourceHistory(resourceType, resourceId, 10)
|
||||
setLogs(response.logs)
|
||||
} catch (error) {
|
||||
console.error('Failed to load resource history:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatChanges = (changes: AuditLog['changes']): string => {
|
||||
if (!changes || changes.length === 0) return ''
|
||||
return changes.map(c => `${c.field}: ${c.old_value ?? 'null'} → ${c.new_value ?? 'null'}`).join(', ')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading history...</div>
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return <div style={styles.empty}>No change history available</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<span style={styles.title}>{title}</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} style={styles.logItem}>
|
||||
<div style={styles.logHeader}>
|
||||
<span style={styles.eventType}>{log.event_type}</span>
|
||||
<span style={styles.time}>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.logBody}>
|
||||
<span style={styles.userName}>{log.user_name || 'System'}</span>
|
||||
{log.changes && log.changes.length > 0 && (
|
||||
<span style={styles.changes}>{formatChanges(log.changes)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginTop: '16px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
},
|
||||
toggleIcon: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
content: {
|
||||
borderTop: '1px solid #e9ecef',
|
||||
padding: '8px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
logItem: {
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
},
|
||||
logHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
eventType: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#007bff',
|
||||
},
|
||||
time: {
|
||||
fontSize: '11px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
logBody: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
},
|
||||
userName: {
|
||||
fontSize: '12px',
|
||||
color: '#495057',
|
||||
},
|
||||
changes: {
|
||||
fontSize: '11px',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
loading: {
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
},
|
||||
empty: {
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}
|
||||
|
||||
export default ResourceHistory
|
||||
Reference in New Issue
Block a user