mod-portal-v2/app/pages/audit/log/index.vue
2026-08-05 06:36:58 +02:00

561 lines
No EOL
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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
:src="log.editor_pid ? `https://mii.spfn.net/${log.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
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
:src="log.target_pid ? `https://mii.spfn.net/${log.target_pid}/main.png` : '/img_unknown_MiiIcon.png'"
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>