EDMS/components/dms/audit/AuditLogViewer.vue
2025-06-05 14:57:08 +08:00

712 lines
25 KiB
Vue

<script setup>
import { ref, computed, onMounted, watch } from 'vue';
const props = defineProps({
documentId: {
type: String,
default: null
},
userId: {
type: String,
default: null
},
showFilters: {
type: Boolean,
default: true
},
pageSize: {
type: Number,
default: 25
}
});
const emit = defineEmits(['log-selected', 'export-logs']);
// Local state
const auditLogs = ref([]);
const isLoading = ref(false);
const currentPage = ref(1);
const totalLogs = ref(0);
const selectedLog = ref(null);
const showDetails = ref(false);
// Filters
const filters = ref({
dateRange: {
start: null,
end: null
},
actions: [],
users: [],
ipAddresses: [],
searchQuery: '',
severity: []
});
// Filter options
const actionTypes = [
{ value: 'view', label: 'View Document', icon: 'mdi:eye', color: 'blue' },
{ value: 'download', label: 'Download', icon: 'mdi:download', color: 'green' },
{ value: 'upload', label: 'Upload', icon: 'mdi:upload', color: 'purple' },
{ value: 'edit', label: 'Edit/Modify', icon: 'mdi:pencil', color: 'orange' },
{ value: 'delete', label: 'Delete', icon: 'mdi:delete', color: 'red' },
{ value: 'share', label: 'Share', icon: 'mdi:share', color: 'teal' },
{ value: 'permission_change', label: 'Permission Change', icon: 'mdi:shield-account', color: 'indigo' },
{ value: 'version_create', label: 'Create Version', icon: 'mdi:source-branch', color: 'emerald' },
{ value: 'version_restore', label: 'Restore Version', icon: 'mdi:restore', color: 'amber' },
{ value: 'login', label: 'User Login', icon: 'mdi:login', color: 'cyan' },
{ value: 'logout', label: 'User Logout', icon: 'mdi:logout', color: 'gray' },
{ value: 'access_granted', label: 'Access Granted', icon: 'mdi:check-circle', color: 'green' },
{ value: 'access_denied', label: 'Access Denied', icon: 'mdi:close-circle', color: 'red' }
];
const severityLevels = [
{ value: 'info', label: 'Information', color: 'blue' },
{ value: 'warning', label: 'Warning', color: 'yellow' },
{ value: 'error', label: 'Error', color: 'red' },
{ value: 'critical', label: 'Critical', color: 'purple' }
];
// Mock audit log data - replace with API call
const mockAuditLogs = [
{
id: 'audit_1',
timestamp: '2023-12-20T14:30:00Z',
action: 'view',
severity: 'info',
userId: 'user1',
userName: 'Aiman Fakhrullah',
userEmail: 'aiman@example.com',
documentId: 'doc_123',
documentName: 'Annual_Budget_2024.xlsx',
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
action: 'Document viewed',
duration: '2 minutes 30 seconds',
location: '/private-cabinet/finance',
method: 'web_interface'
},
changes: null,
success: true
},
{
id: 'audit_2',
timestamp: '2023-12-20T14:25:00Z',
action: 'download',
severity: 'info',
userId: 'user1',
userName: 'Aiman Fakhrullah',
userEmail: 'aiman@example.com',
documentId: 'doc_456',
documentName: 'Project_Plan_2024.pdf',
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
action: 'Document downloaded',
fileSize: '2.5 MB',
downloadMethod: 'direct_download',
location: '/public-cabinet/projects'
},
changes: null,
success: true
},
{
id: 'audit_3',
timestamp: '2023-12-20T14:20:00Z',
action: 'access_denied',
severity: 'warning',
userId: 'user2',
userName: 'Sarah Ahmed',
userEmail: 'sarah@example.com',
documentId: 'doc_789',
documentName: 'Confidential_Report.docx',
ipAddress: '192.168.1.105',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
details: {
action: 'Access denied to confidential document',
reason: 'Insufficient permissions',
attemptedAction: 'view',
location: '/private-cabinet/confidential'
},
changes: null,
success: false
},
{
id: 'audit_4',
timestamp: '2023-12-20T14:15:00Z',
action: 'upload',
severity: 'info',
userId: 'user3',
userName: 'Ahmad Hassan',
userEmail: 'ahmad@example.com',
documentId: 'doc_new_001',
documentName: 'Meeting_Minutes_Dec2023.docx',
ipAddress: '192.168.1.110',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
action: 'New document uploaded',
fileSize: '1.2 MB',
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
location: '/public-cabinet/meetings',
metadata: {
title: 'Meeting Minutes December 2023',
department: 'HR',
tags: ['meeting', 'minutes', 'hr']
}
},
changes: {
type: 'create',
description: 'Document created with initial metadata'
},
success: true
}
];
// Computed properties
const filteredLogs = computed(() => {
let logs = [...auditLogs.value];
// Apply filters
if (filters.value.searchQuery) {
const query = filters.value.searchQuery.toLowerCase();
logs = logs.filter(log =>
log.userName.toLowerCase().includes(query) ||
log.documentName.toLowerCase().includes(query) ||
log.details.action.toLowerCase().includes(query) ||
log.ipAddress.includes(query)
);
}
if (filters.value.actions.length > 0) {
logs = logs.filter(log => filters.value.actions.includes(log.action));
}
if (filters.value.users.length > 0) {
logs = logs.filter(log => filters.value.users.includes(log.userId));
}
if (filters.value.severity.length > 0) {
logs = logs.filter(log => filters.value.severity.includes(log.severity));
}
if (filters.value.dateRange.start && filters.value.dateRange.end) {
const start = new Date(filters.value.dateRange.start);
const end = new Date(filters.value.dateRange.end);
logs = logs.filter(log => {
const logDate = new Date(log.timestamp);
return logDate >= start && logDate <= end;
});
}
return logs;
});
const paginatedLogs = computed(() => {
const start = (currentPage.value - 1) * props.pageSize;
const end = start + props.pageSize;
return filteredLogs.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredLogs.value.length / props.pageSize);
});
// Methods
const loadAuditLogs = async () => {
isLoading.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// In real implementation, this would be an API call
auditLogs.value = mockAuditLogs;
totalLogs.value = mockAuditLogs.length;
} catch (error) {
console.error('Failed to load audit logs:', error);
} finally {
isLoading.value = false;
}
};
const getActionConfig = (action) => {
return actionTypes.find(type => type.value === action) || {
value: action,
label: action,
icon: 'mdi:information',
color: 'gray'
};
};
const getSeverityConfig = (severity) => {
return severityLevels.find(level => level.value === severity) || {
value: severity,
label: severity,
color: 'gray'
};
};
const formatTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString();
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const date = new Date(timestamp);
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) return 'Just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
return `${Math.floor(diffInSeconds / 86400)} days ago`;
};
const selectLog = (log) => {
selectedLog.value = log;
showDetails.value = true;
emit('log-selected', log);
};
const clearFilters = () => {
filters.value = {
dateRange: { start: null, end: null },
actions: [],
users: [],
ipAddresses: [],
searchQuery: '',
severity: []
};
currentPage.value = 1;
};
const exportLogs = () => {
emit('export-logs', filteredLogs.value);
};
const changePage = (page) => {
currentPage.value = page;
};
// Watch for prop changes
watch(() => props.documentId, () => {
if (props.documentId) {
// Filter logs for specific document
loadAuditLogs();
}
});
watch(() => props.userId, () => {
if (props.userId) {
// Filter logs for specific user
filters.value.users = [props.userId];
}
});
// Lifecycle
onMounted(() => {
loadAuditLogs();
});
</script>
<template>
<div class="audit-log-viewer">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Audit Log
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Showing {{ filteredLogs.length }} of {{ totalLogs }} audit entries
</p>
</div>
<div class="flex space-x-2">
<button
@click="exportLogs"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm"
>
<Icon name="mdi:download" class="w-4 h-4 mr-2 inline" />
Export
</button>
<button
@click="loadAuditLogs"
:disabled="isLoading"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm disabled:opacity-50"
>
<Icon name="mdi:refresh" class="w-4 h-4 mr-2 inline" />
Refresh
</button>
</div>
</div>
<!-- Filters -->
<div v-if="showFilters" class="filters mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search
</label>
<input
v-model="filters.searchQuery"
type="text"
placeholder="Search users, documents, actions..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-sm"
/>
</div>
<!-- Date Range -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Date From
</label>
<input
v-model="filters.dateRange.start"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Date To
</label>
<input
v-model="filters.dateRange.end"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-sm"
/>
</div>
<!-- Actions Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Actions
</label>
<select
v-model="filters.actions"
multiple
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-sm"
>
<option v-for="action in actionTypes" :key="action.value" :value="action.value">
{{ action.label }}
</option>
</select>
</div>
</div>
<div class="flex justify-between items-center">
<div class="flex flex-wrap gap-2">
<!-- Severity filter chips -->
<button
v-for="severity in severityLevels"
:key="severity.value"
@click="
filters.severity.includes(severity.value)
? filters.severity = filters.severity.filter(s => s !== severity.value)
: filters.severity.push(severity.value)
"
class="px-3 py-1 rounded-full text-xs font-medium transition-colors"
:class="
filters.severity.includes(severity.value)
? `bg-${severity.color}-100 text-${severity.color}-800 dark:bg-${severity.color}-900/20 dark:text-${severity.color}-200`
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200'
"
>
{{ severity.label }}
</button>
</div>
<button
@click="clearFilters"
class="px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Clear all filters
</button>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading audit logs...</p>
</div>
<!-- Audit log table -->
<div v-else class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Timestamp
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Action
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Document
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IP Address
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
<tr
v-for="log in paginatedLogs"
:key="log.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
@click="selectLog(log)"
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>{{ formatTimestamp(log.timestamp) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ getTimeAgo(log.timestamp) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<Icon
:name="getActionConfig(log.action).icon"
class="w-4 h-4 mr-2"
:class="`text-${getActionConfig(log.action).color}-500`"
/>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ getActionConfig(log.action).label }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ log.userName }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ log.userEmail }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{{ log.documentName || 'N/A' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ log.ipAddress }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="
log.success
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-200'
"
>
{{ log.success ? 'Success' : 'Failed' }}
</span>
<span
class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="`bg-${getSeverityConfig(log.severity).color}-100 text-${getSeverityConfig(log.severity).color}-800 dark:bg-${getSeverityConfig(log.severity).color}-900/20 dark:text-${getSeverityConfig(log.severity).color}-200`"
>
{{ getSeverityConfig(log.severity).label }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<button
@click.stop="selectLog(log)"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
View Details
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-if="paginatedLogs.length === 0" class="text-center py-8">
<Icon name="mdi:file-document-outline" class="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No audit logs found
</h3>
<p class="text-gray-500 dark:text-gray-400">
Try adjusting your filters or search criteria.
</p>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6">
<div class="text-sm text-gray-700 dark:text-gray-300">
Showing {{ (currentPage - 1) * pageSize + 1 }} to {{ Math.min(currentPage * pageSize, filteredLogs.length) }} of {{ filteredLogs.length }} results
</div>
<div class="flex space-x-1">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Previous
</button>
<button
v-for="page in Math.min(5, totalPages)"
:key="page"
@click="changePage(page)"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm"
:class="page === currentPage ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-50 dark:hover:bg-gray-700'"
>
{{ page }}
</button>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Next
</button>
</div>
</div>
<!-- Audit log detail modal -->
<rs-modal :visible="showDetails" @close="showDetails = false" size="4xl">
<template #header>
<h3 class="text-lg font-semibold">Audit Log Details</h3>
</template>
<template #body>
<div v-if="selectedLog" class="space-y-6">
<!-- Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">Basic Information</h4>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Timestamp:</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ formatTimestamp(selectedLog.timestamp) }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Action:</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ getActionConfig(selectedLog.action).label }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Severity:</dt>
<dd>
<span
class="px-2 py-1 rounded-full text-xs font-medium"
:class="`bg-${getSeverityConfig(selectedLog.severity).color}-100 text-${getSeverityConfig(selectedLog.severity).color}-800`"
>
{{ getSeverityConfig(selectedLog.severity).label }}
</span>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Status:</dt>
<dd>
<span
class="px-2 py-1 rounded-full text-xs font-medium"
:class="
selectedLog.success
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
"
>
{{ selectedLog.success ? 'Success' : 'Failed' }}
</span>
</dd>
</div>
</dl>
</div>
<div>
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">User Information</h4>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">User:</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ selectedLog.userName }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Email:</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ selectedLog.userEmail }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">IP Address:</dt>
<dd class="text-gray-900 dark:text-gray-100 font-mono">{{ selectedLog.ipAddress }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">User Agent:</dt>
<dd class="text-gray-900 dark:text-gray-100 text-xs break-all">{{ selectedLog.userAgent }}</dd>
</div>
</dl>
</div>
</div>
<!-- Document Information -->
<div v-if="selectedLog.documentId">
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">Document Information</h4>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Document:</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ selectedLog.documentName }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Document ID:</dt>
<dd class="text-gray-900 dark:text-gray-100 font-mono">{{ selectedLog.documentId }}</dd>
</div>
</dl>
</div>
<!-- Action Details -->
<div>
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">Action Details</h4>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<pre class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap">{{ JSON.stringify(selectedLog.details, null, 2) }}</pre>
</div>
</div>
<!-- Changes -->
<div v-if="selectedLog.changes">
<h4 class="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">Changes Made</h4>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<pre class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap">{{ JSON.stringify(selectedLog.changes, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<button
@click="showDetails = false"
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Close
</button>
</div>
</template>
</rs-modal>
</div>
</template>
<style scoped>
.audit-log-viewer table {
min-width: 800px;
}
</style>