mod-portal-v2/app/pages/audit/log/index.vue

561 lines
15 KiB
Vue
Raw Normal View History

2026-08-05 06:36:58 +02:00
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useAuth } from '~/composables/useAuth.js'
const route = useRoute()
const router = useRouter()
const {
isAuthenticated,
isInitialized,
userProfile,
initializeAuth,
logout
} = useAuth()
const logs = ref([])
const totalLogs = ref(0)
const isLoading = ref(true)
const errorMessage = ref('')
const searchQuery = ref(String(route.query.q || ''))
const selectedType = ref(String(route.query.type || 'all'))
const dateFrom = ref(String(route.query.from || ''))
const dateTo = ref(String(route.query.to || ''))
const currentPage = ref(Number(route.query.page) || 1)
const limit = ref(Number(route.query.limit) || 20)
const FIELD_LABELS = {
account_level: 'Access Level',
country: 'Country',
language: 'Language',
timezone: 'Timezone',
gender: 'Gender',
email: 'Email',
marketing_allowed: 'Marketing Allowed',
off_device_allowed: 'Off-Device Allowed'
}
const totalPages = computed(() => Math.ceil(totalLogs.value / limit.value) || 1)
const visiblePages = computed(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const formatDate = (isoStr) => {
if (!isoStr) return 'N/A'
try {
return new Date(isoStr).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short'
})
} catch {
return isoStr
}
}
const parseLogChanges = (rawChanges) => {
if (!rawChanges) return { changes: [], reason: '', mod_note: '' }
if (typeof rawChanges === 'string') {
try {
return JSON.parse(rawChanges)
} catch {
return { changes: [], reason: '', mod_note: '' }
}
}
return rawChanges
}
const updateUrlParams = () => {
const query = {}
if (searchQuery.value.trim()) query.q = searchQuery.value.trim()
if (selectedType.value && selectedType.value !== 'all') query.type = selectedType.value
if (dateFrom.value) query.from = dateFrom.value
if (dateTo.value) query.to = dateTo.value
if (currentPage.value > 1) query.page = currentPage.value
if (limit.value !== 20) query.limit = limit.value
router.replace({ query })
}
const fetchLogs = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const queryParams = new URLSearchParams({
page: currentPage.value,
limit: limit.value,
search: searchQuery.value,
type: selectedType.value
})
if (dateFrom.value) queryParams.append('from', dateFrom.value)
if (dateTo.value) queryParams.append('to', dateTo.value)
const response = await $fetch(`/api/admin/audit-logs?${queryParams.toString()}`)
logs.value = response.logs || []
totalLogs.value = response.total || 0
} catch (err) {
console.error('Failed to fetch audit logs:', err)
errorMessage.value = err.data?.statusMessage || 'Failed to fetch audit logs.'
} finally {
isLoading.value = false
}
}
let searchDebounceTimeout = null
watch(searchQuery, () => {
clearTimeout(searchDebounceTimeout)
searchDebounceTimeout = setTimeout(() => {
currentPage.value = 1
updateUrlParams()
fetchLogs()
}, 400)
})
watch([selectedType, dateFrom, dateTo, limit], () => {
currentPage.value = 1
updateUrlParams()
fetchLogs()
})
const openLogDetails = (log) => {
const targetLogId = log.log_id || log.id
navigateTo(`/audit/log/${targetLogId}`)
}
const goToPage = (page) => {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
currentPage.value = page
updateUrlParams()
fetchLogs()
}
const handleLogout = () => logout()
const handleNavigate = (path) => navigateTo(path)
onMounted(async () => {
await initializeAuth({ redirectTo: '/oauth' })
if (isAuthenticated.value) {
await fetchLogs()
}
})
</script>
<template>
<div>
<div v-if="!isInitialized" class="global-loader-wrapper">
<div class="spinner lg"></div>
<p style="color: #8b949e; margin-top: 1rem; font-size: 0.95rem;">Connecting to Keycloak...</p>
</div>
<div v-else-if="isAuthenticated" class="settings-container">
<Header
:user-profile="userProfile"
active-tab="audit"
@logout="handleLogout"
@navigate="handleNavigate"
/>
<main class="settings-main-content">
<section class="settings-panel" style="width: 100%;">
<div class="panel-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
<span>SYSTEM AUDIT LOGS</span>
<span class="badge-count">{{ totalLogs }} total entries</span>
</div>
<div class="panel-body" style="padding: 1.5rem;">
<div class="filter-bar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
class="input-field"
placeholder="Search by Log ID, Editor, Target, or PID..."
/>
</div>
<div class="filter-group">
<select v-model="selectedType" class="input-field select-filter">
<option value="all">All Audit Logs</option>
<option value="ban">Ban Logs Only</option>
</select>
<input
v-model="dateFrom"
type="date"
class="input-field date-filter"
title="Filter from date"
/>
<input
v-model="dateTo"
type="date"
class="input-field date-filter"
title="Filter to date"
/>
</div>
</div>
<div v-if="errorMessage" class="alert-error">
{{ errorMessage }}
</div>
<div v-if="isLoading" class="loading-state">
<div class="spinner sm"></div>
<span>Fetching audit logs...</span>
</div>
<div v-else-if="logs.length > 0" class="table-wrapper">
<table class="audit-table">
<thead>
<tr>
<th>LOG ID</th>
<th>TIMESTAMP</th>
<th>EDITOR</th>
<th>TARGET USER</th>
<th>CHANGES SUMMARY</th>
<th>ACTION</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.log_id || log.id">
<td class="font-mono text-dim">
#{{ log.log_id || log.id }}
</td>
<td class="text-nowrap">
{{ formatDate(log.created_at) }}
</td>
<td>
<div class="user-cell">
<img
2026-08-09 21:57:29 +02:00
:src="log.editor_pid ? `https://mii.spbr.net/${log.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
2026-08-05 06:36:58 +02:00
class="avatar-xs"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<span>{{ log.editor_sfid || 'System / Unknown' }}</span>
</div>
</td>
<td>
<div class="user-cell">
<img
2026-08-09 21:57:29 +02:00
:src="log.target_pid ? `https://mii.spbr.net/${log.target_pid}/main.png` : '/img_unknown_MiiIcon.png'"
2026-08-05 06:36:58 +02:00
class="avatar-xs"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<div>
<strong>{{ log.target_name }}</strong>
<span class="pid-tag">PID: {{ log.target_pid }}</span>
</div>
</div>
</td>
<td>
<div class="changes-summary">
<template v-if="log.ban_level !== undefined && log.ban_level !== null">
<span class="badge-ban">Banned (Level {{ log.ban_level }})</span>
</template>
<template v-else>
<span
v-for="change in parseLogChanges(log.changes).changes"
:key="change.field"
class="change-chip"
>
{{ FIELD_LABELS[change.field] || change.field }}
</span>
</template>
</div>
</td>
<td>
<button
type="button"
class="btn-action secondary sm"
@click="openLogDetails(log)"
>
Inspect
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<p>No audit logs found matching your criteria.</p>
</div>
<div class="pagination-bar">
<div class="per-page-selector">
<label for="limit-select">Show:</label>
<select id="limit-select" v-model="limit" class="input-field select-sm">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="range-info">
Showing {{ ((currentPage - 1) * limit) + 1 }}{{ Math.min(currentPage * limit, totalLogs) }} of {{ totalLogs }}
</span>
</div>
<div class="pagination-controls">
<button
class="btn-action secondary sm"
:disabled="currentPage === 1"
title="First Page"
@click="goToPage(1)"
>
«
</button>
<button
class="btn-action secondary sm"
:disabled="currentPage === 1"
title="Previous Page"
@click="goToPage(currentPage - 1)"
>
</button>
<button
v-for="page in visiblePages"
:key="page"
class="btn-action sm page-number"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page }}
</button>
<button
class="btn-action secondary sm"
:disabled="currentPage === totalPages"
title="Next Page"
@click="goToPage(currentPage + 1)"
>
</button>
<button
class="btn-action secondary sm"
:disabled="currentPage === totalPages"
title="Last Page"
@click="goToPage(totalPages)"
>
»
</button>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-box {
flex: 1;
min-width: 280px;
}
.filter-group {
display: flex;
gap: 0.5rem;
}
.select-filter, .date-filter {
width: auto;
}
.table-wrapper {
overflow-x: auto;
border: 1px solid #30363d;
border-radius: 6px;
background: #0d1117;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
text-align: left;
}
.audit-table th {
background: #161b22;
color: #8b949e;
padding: 0.75rem 1rem;
font-size: 0.75rem;
text-transform: uppercase;
border-bottom: 1px solid #30363d;
}
.audit-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid #21262d;
color: #c9d1d9;
}
.audit-table tr:last-child td {
border-bottom: none;
}
.user-cell {
display: flex;
align-items: center;
gap: 0.6rem;
}
.avatar-xs {
width: 28px;
height: 28px;
border-radius: 50%;
background: #161b22;
}
.pid-tag {
display: block;
font-size: 0.75rem;
color: #8b949e;
}
.changes-summary {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.change-chip {
background: #21262d;
border: 1px solid #30363d;
color: #58a6ff;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
}
.badge-ban {
background: #490202;
border: 1px solid #8b0000;
color: #ff7b72;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
}
.badge-count {
font-size: 0.8rem;
background: #21262d;
color: #8b949e;
padding: 0.2rem 0.6rem;
border-radius: 12px;
}
.pagination-bar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #30363d;
}
.per-page-selector {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #8b949e;
}
.select-sm {
padding: 0.2rem 0.4rem;
font-size: 0.85rem;
}
.range-info {
margin-left: 0.5rem;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.35rem;
}
.page-number {
background: transparent;
border: 1px solid #30363d;
color: #c9d1d9;
min-width: 32px;
}
.page-number.active {
background: #238636;
border-color: #2ea043;
color: #ffffff;
font-weight: bold;
}
.empty-state, .loading-state {
text-align: center;
padding: 3rem 1rem;
color: #8b949e;
}
.alert-error {
padding: 0.85rem;
background: #221515;
border: 1px solid #f85149;
color: #f85149;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.input-field {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
color: #f0f6fc;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
outline: none;
}
.btn-action {
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-action.secondary { background: transparent; border: 1px solid #30363d; color: #c9d1d9; }
.btn-action.sm { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
.btn-action:disabled { opacity: 0.35; cursor: not-allowed; }
</style>