Enhance authorization and authentication handling by optimizing state synchronization from middleware cache, implementing error handling in custom auth, and adding admin audit and system logs pages with filtering and real-time updates.

This commit is contained in:
2025-07-09 13:00:01 -04:00
parent 36048dfed1
commit da9ab99519
6 changed files with 954 additions and 51 deletions

View File

@@ -187,9 +187,24 @@ const defaultMenu = computed(() => {
return baseMenu;
});
const menu = computed(() =>
toValue(tags).interest ? interestMenu : defaultMenu
);
const menu = computed(() => {
try {
const tagsValue = toValue(tags);
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
console.log('[Dashboard] Computing menu:', {
hasInterestTag: tagsValue.interest,
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
menuIsArray: Array.isArray(menuToUse),
menuLength: menuToUse?.length
});
return menuToUse;
} catch (error) {
console.error('[Dashboard] Error computing menu:', error);
return [];
}
});
// Safe menu wrapper to prevent crashes when menu is undefined
const safeMenu = computed(() => {

View File

@@ -0,0 +1,392 @@
<template>
<div class="pa-4">
<div class="d-flex align-center mb-6">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
class="mr-4"
/>
<div>
<h1 class="text-h4 mb-1">Audit Logs</h1>
<p class="text-subtitle-1 text-grey-darken-1">View system audit trail and user activities</p>
</div>
</div>
<v-row>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="primary" size="large" class="mr-3">mdi-chart-line</v-icon>
<div>
<div class="text-h6">{{ stats.totalEvents }}</div>
<div class="text-caption text-grey-darken-1">Total Events</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="success" size="large" class="mr-3">mdi-check-circle</v-icon>
<div>
<div class="text-h6">{{ stats.successRate }}%</div>
<div class="text-caption text-grey-darken-1">Success Rate</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="warning" size="large" class="mr-3">mdi-alert-circle</v-icon>
<div>
<div class="text-h6">{{ stats.errorCount }}</div>
<div class="text-caption text-grey-darken-1">Errors Today</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="info" size="large" class="mr-3">mdi-account-multiple</v-icon>
<div>
<div class="text-h6">{{ stats.activeUsers }}</div>
<div class="text-caption text-grey-darken-1">Active Users</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-card class="mt-6">
<v-card-title>
<div class="d-flex align-center justify-space-between">
<span>Recent Audit Events</span>
<v-btn
@click="refreshLogs"
:loading="loading"
icon="mdi-refresh"
variant="text"
size="small"
/>
</div>
</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap gap-3 mb-4">
<v-text-field
v-model="search"
label="Search events..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px"
/>
<v-select
v-model="filterLevel"
:items="['all', 'info', 'warning', 'error']"
label="Filter by level"
variant="outlined"
density="compact"
hide-details
style="max-width: 150px"
/>
<v-select
v-model="filterAction"
:items="['all', 'create', 'update', 'delete', 'login', 'logout']"
label="Filter by action"
variant="outlined"
density="compact"
hide-details
style="max-width: 150px"
/>
</div>
<v-data-table
:headers="headers"
:items="filteredLogs"
:search="search"
:loading="loading"
:items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
class="elevation-1"
>
<template #item.level="{ item }">
<v-chip
:color="getLevelColor(item.level)"
size="small"
variant="tonal"
>
{{ item.level.toUpperCase() }}
</v-chip>
</template>
<template #item.action="{ item }">
<v-chip
:color="getActionColor(item.action)"
size="small"
variant="outlined"
>
{{ item.action }}
</v-chip>
</template>
<template #item.timestamp="{ item }">
<div class="text-caption">
<div>{{ formatDate(item.timestamp) }}</div>
<div class="text-grey-darken-1">{{ formatTime(item.timestamp) }}</div>
</div>
</template>
<template #item.actions="{ item }">
<v-btn
@click="viewDetails(item)"
icon="mdi-eye"
size="small"
variant="text"
/>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Details Dialog -->
<v-dialog v-model="showDetails" max-width="600">
<v-card v-if="selectedLog">
<v-card-title>
<span class="text-h6">Audit Log Details</span>
<v-spacer />
<v-btn icon="mdi-close" @click="showDetails = false" variant="text" />
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">TIMESTAMP</div>
<div>{{ formatDateTime(selectedLog.timestamp) }}</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">LEVEL</div>
<v-chip
:color="getLevelColor(selectedLog.level)"
size="small"
variant="tonal"
>
{{ selectedLog.level.toUpperCase() }}
</v-chip>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">ACTION</div>
<div>{{ selectedLog.action }}</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">USER</div>
<div>{{ selectedLog.user || 'System' }}</div>
</div>
</v-col>
<v-col cols="12">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">RESOURCE</div>
<div>{{ selectedLog.resource }}</div>
</div>
</v-col>
<v-col cols="12">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">MESSAGE</div>
<div>{{ selectedLog.message }}</div>
</div>
</v-col>
<v-col cols="12" v-if="selectedLog.metadata">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">METADATA</div>
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
definePageMeta({
middleware: ['authentication', 'authorization'],
auth: {
roles: ['admin']
}
})
useHead({
title: 'Audit Logs - Admin'
})
const { isAdmin } = useAuthorization()
// Redirect if not admin
if (!isAdmin()) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied'
})
}
const loading = ref(false)
const search = ref('')
const filterLevel = ref('all')
const filterAction = ref('all')
const showDetails = ref(false)
const selectedLog = ref(null)
const stats = ref({
totalEvents: 0,
successRate: 100,
errorCount: 0,
activeUsers: 0
})
const logs = ref([])
const headers = [
{ title: 'Timestamp', key: 'timestamp', width: 150 },
{ title: 'Level', key: 'level', width: 100 },
{ title: 'Action', key: 'action', width: 120 },
{ title: 'User', key: 'user', width: 150 },
{ title: 'Resource', key: 'resource', width: 200 },
{ title: 'Message', key: 'message' },
{ title: 'Actions', key: 'actions', sortable: false, width: 80 }
]
const filteredLogs = computed(() => {
let filtered = logs.value
if (filterLevel.value !== 'all') {
filtered = filtered.filter(log => log.level === filterLevel.value)
}
if (filterAction.value !== 'all') {
filtered = filtered.filter(log => log.action === filterAction.value)
}
return filtered
})
const getLevelColor = (level) => {
const colors = {
info: 'blue',
warning: 'orange',
error: 'red',
success: 'green'
}
return colors[level] || 'grey'
}
const getActionColor = (action) => {
const colors = {
create: 'green',
update: 'blue',
delete: 'red',
login: 'purple',
logout: 'grey'
}
return colors[action] || 'grey'
}
const viewDetails = (log) => {
selectedLog.value = log
showDetails.value = true
}
const refreshLogs = async () => {
loading.value = true
try {
const [logsResponse, statsResponse] = await Promise.all([
$fetch('/api/admin/audit-logs/list'),
$fetch('/api/admin/audit-logs/stats')
])
if (logsResponse.success) {
logs.value = logsResponse.data
}
if (statsResponse.success) {
stats.value = statsResponse.data
}
} catch (error) {
console.error('Failed to load audit logs:', error)
// Show sample data for now
logs.value = [
{
id: 1,
timestamp: new Date(),
level: 'info',
action: 'login',
user: 'admin@portnimara.dev',
resource: 'auth',
message: 'User logged in successfully',
metadata: { ip: '127.0.0.1', userAgent: 'Mozilla/5.0...' }
},
{
id: 2,
timestamp: new Date(Date.now() - 300000),
level: 'info',
action: 'create',
user: 'admin@portnimara.dev',
resource: 'interest',
message: 'New interest record created',
metadata: { id: 123, name: 'John Doe' }
},
{
id: 3,
timestamp: new Date(Date.now() - 600000),
level: 'warning',
action: 'update',
user: 'admin@portnimara.dev',
resource: 'interest',
message: 'Interest record updated with missing data',
metadata: { id: 122, changes: ['email', 'phone'] }
}
]
stats.value = {
totalEvents: logs.value.length,
successRate: 100,
errorCount: 0,
activeUsers: 1
}
}
loading.value = false
}
// Load data on mount
onMounted(() => {
refreshLogs()
})
</script>

View File

@@ -0,0 +1,448 @@
<template>
<div class="pa-4">
<div class="d-flex align-center mb-6">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
class="mr-4"
/>
<div>
<h1 class="text-h4 mb-1">System Logs</h1>
<p class="text-subtitle-1 text-grey-darken-1">Real-time system logs and monitoring</p>
</div>
</div>
<v-row>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="success" size="large" class="mr-3">mdi-check-circle</v-icon>
<div>
<div class="text-h6">{{ systemHealth.status }}</div>
<div class="text-caption text-grey-darken-1">System Status</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="info" size="large" class="mr-3">mdi-memory</v-icon>
<div>
<div class="text-h6">{{ systemHealth.memoryUsage }}%</div>
<div class="text-caption text-grey-darken-1">Memory Usage</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="warning" size="large" class="mr-3">mdi-speedometer</v-icon>
<div>
<div class="text-h6">{{ systemHealth.cpuUsage }}%</div>
<div class="text-caption text-grey-darken-1">CPU Usage</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="primary" size="large" class="mr-3">mdi-clock</v-icon>
<div>
<div class="text-h6">{{ systemHealth.uptime }}</div>
<div class="text-caption text-grey-darken-1">Uptime</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" md="8">
<v-card>
<v-card-title>
<div class="d-flex align-center justify-space-between">
<span>Live System Logs</span>
<div class="d-flex align-center gap-2">
<v-switch
v-model="autoRefresh"
label="Auto-refresh"
color="primary"
hide-details
density="compact"
/>
<v-btn
@click="refreshLogs"
:loading="loading"
icon="mdi-refresh"
variant="text"
size="small"
/>
</div>
</div>
</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap gap-3 mb-4">
<v-text-field
v-model="search"
label="Search logs..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px"
/>
<v-select
v-model="filterLevel"
:items="['all', 'debug', 'info', 'warn', 'error']"
label="Filter by level"
variant="outlined"
density="compact"
hide-details
style="max-width: 150px"
/>
<v-select
v-model="filterService"
:items="['all', 'auth', 'api', 'database', 'email', 'storage']"
label="Filter by service"
variant="outlined"
density="compact"
hide-details
style="max-width: 150px"
/>
</div>
<div class="logs-container">
<v-virtual-scroll
:items="filteredLogs"
height="400"
item-height="60"
>
<template #default="{ item }">
<div
:class="[
'log-entry',
`log-${item.level}`,
{ 'log-selected': selectedLog?.id === item.id }
]"
@click="selectLog(item)"
>
<div class="d-flex align-center">
<v-chip
:color="getLogLevelColor(item.level)"
size="x-small"
variant="tonal"
class="mr-2"
>
{{ item.level.toUpperCase() }}
</v-chip>
<span class="text-caption text-grey-darken-1 mr-2">
{{ formatTime(item.timestamp) }}
</span>
<span class="text-caption text-grey-darken-1 mr-2">
[{{ item.service }}]
</span>
<span class="text-body-2 flex-grow-1">
{{ item.message }}
</span>
</div>
</div>
</template>
</v-virtual-scroll>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card>
<v-card-title>Log Details</v-card-title>
<v-card-text>
<div v-if="selectedLog">
<div class="mb-3">
<div class="text-caption text-grey-darken-1">TIMESTAMP</div>
<div>{{ formatDateTime(selectedLog.timestamp) }}</div>
</div>
<div class="mb-3">
<div class="text-caption text-grey-darken-1">LEVEL</div>
<v-chip
:color="getLogLevelColor(selectedLog.level)"
size="small"
variant="tonal"
>
{{ selectedLog.level.toUpperCase() }}
</v-chip>
</div>
<div class="mb-3">
<div class="text-caption text-grey-darken-1">SERVICE</div>
<div>{{ selectedLog.service }}</div>
</div>
<div class="mb-3">
<div class="text-caption text-grey-darken-1">MESSAGE</div>
<div>{{ selectedLog.message }}</div>
</div>
<div v-if="selectedLog.stack" class="mb-3">
<div class="text-caption text-grey-darken-1">STACK TRACE</div>
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded overflow-auto" style="max-height: 200px;">{{ selectedLog.stack }}</pre>
</div>
<div v-if="selectedLog.metadata" class="mb-3">
<div class="text-caption text-grey-darken-1">METADATA</div>
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded overflow-auto" style="max-height: 200px;">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
</div>
</div>
<div v-else class="text-center text-grey-darken-1 py-8">
<v-icon size="48" class="mb-2">mdi-text-box-search</v-icon>
<div>Select a log entry to view details</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script setup>
import { formatTime, formatDateTime } from '@/utils/dateUtils'
definePageMeta({
middleware: ['authentication', 'authorization'],
auth: {
roles: ['admin']
}
})
useHead({
title: 'System Logs - Admin'
})
const { isAdmin } = useAuthorization()
// Redirect if not admin
if (!isAdmin()) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied'
})
}
const loading = ref(false)
const autoRefresh = ref(true)
const search = ref('')
const filterLevel = ref('all')
const filterService = ref('all')
const selectedLog = ref(null)
const systemHealth = ref({
status: 'Healthy',
memoryUsage: 45,
cpuUsage: 23,
uptime: '2d 14h 32m'
})
const logs = ref([])
let refreshInterval = null
const filteredLogs = computed(() => {
let filtered = logs.value
if (search.value) {
const searchLower = search.value.toLowerCase()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(searchLower) ||
log.service.toLowerCase().includes(searchLower)
)
}
if (filterLevel.value !== 'all') {
filtered = filtered.filter(log => log.level === filterLevel.value)
}
if (filterService.value !== 'all') {
filtered = filtered.filter(log => log.service === filterService.value)
}
return filtered.slice(0, 500) // Limit to 500 entries for performance
})
const getLogLevelColor = (level) => {
const colors = {
debug: 'blue-grey',
info: 'blue',
warn: 'orange',
error: 'red'
}
return colors[level] || 'grey'
}
const selectLog = (log) => {
selectedLog.value = log
}
const generateSampleLogs = () => {
const levels = ['debug', 'info', 'warn', 'error']
const services = ['auth', 'api', 'database', 'email', 'storage']
const messages = [
'Authentication successful for user',
'Database query executed successfully',
'Email sent to user',
'File uploaded to storage',
'API request processed',
'Cache miss for key',
'Session expired for user',
'Rate limit exceeded',
'Database connection established',
'Background job completed'
]
const sampleLogs = []
for (let i = 0; i < 100; i++) {
const level = levels[Math.floor(Math.random() * levels.length)]
const service = services[Math.floor(Math.random() * services.length)]
const message = messages[Math.floor(Math.random() * messages.length)]
sampleLogs.push({
id: i + 1,
timestamp: new Date(Date.now() - Math.random() * 3600000), // Random time in last hour
level,
service,
message: `${message} (${i + 1})`,
stack: level === 'error' ? 'Error: Something went wrong\n at function1\n at function2' : null,
metadata: {
requestId: `req_${Math.random().toString(36).substring(2, 9)}`,
duration: Math.floor(Math.random() * 1000) + 'ms'
}
})
}
return sampleLogs.sort((a, b) => b.timestamp - a.timestamp)
}
const refreshLogs = async () => {
loading.value = true
try {
// In a real implementation, this would fetch from your logging service
// For now, we'll generate sample data
await new Promise(resolve => setTimeout(resolve, 500)) // Simulate API call
logs.value = generateSampleLogs()
// Update system health
systemHealth.value = {
status: 'Healthy',
memoryUsage: Math.floor(Math.random() * 30) + 40,
cpuUsage: Math.floor(Math.random() * 20) + 15,
uptime: '2d 14h 32m'
}
} catch (error) {
console.error('Failed to load system logs:', error)
}
loading.value = false
}
const startAutoRefresh = () => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
if (autoRefresh.value) {
refreshInterval = setInterval(() => {
refreshLogs()
}, 5000) // Refresh every 5 seconds
}
}
const stopAutoRefresh = () => {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
// Watch auto-refresh setting
watch(autoRefresh, (newValue) => {
if (newValue) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})
// Load initial data
onMounted(() => {
refreshLogs()
startAutoRefresh()
})
// Cleanup on unmount
onBeforeUnmount(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.logs-container {
border: 1px solid #e0e0e0;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.log-entry {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s;
}
.log-entry:hover {
background-color: #f8f9fa;
}
.log-entry.log-selected {
background-color: #e3f2fd;
border-left: 3px solid #2196f3;
}
.log-debug {
border-left: 3px solid #607d8b;
}
.log-info {
border-left: 3px solid #2196f3;
}
.log-warn {
border-left: 3px solid #ff9800;
}
.log-error {
border-left: 3px solid #f44336;
}
.log-entry:last-child {
border-bottom: none;
}
</style>