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

497 lines
12 KiB
Vue
Raw Normal View History

2026-08-05 06:36:58 +02:00
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useAuth } from '~/composables/useAuth.js'
const route = useRoute()
const logId = computed(() => route.params.logid)
const {
isAuthenticated,
isInitialized,
userProfile,
initializeAuth,
logout
} = useAuth()
const logData = ref(null)
const isLoading = ref(true)
const errorMessage = ref('')
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 ACCOUNT_LEVEL_NAMES = {
'-3': 'Omey Banned (-3)',
'-2': 'Inf Eula Banned (-2)',
'-1': 'Banned (-1)',
'0': 'Member (0)',
'1': 'Tester (1)',
'2': 'Moderator (2)',
'3': 'Admin / Developer (3)',
'4': 'Manager (4)'
}
const formatValue = (field, val) => {
if (val === null || val === undefined || val === '') return 'N/A'
if (field === 'account_level') {
return ACCOUNT_LEVEL_NAMES[String(val)] || `Level ${val}`
}
if (typeof val === 'boolean') {
return val ? 'True' : 'False'
}
return String(val)
}
const formatDate = (isoStr) => {
if (!isoStr) return 'N/A'
try {
return new Date(isoStr).toLocaleString('en-US', {
dateStyle: 'full',
timeStyle: 'medium'
})
} catch {
return isoStr
}
}
const parsedChangesPayload = computed(() => {
if (!logData.value?.changes) return { changes: [], reason: '', mod_note: '' }
if (typeof logData.value.changes === 'string') {
try {
return JSON.parse(logData.value.changes)
} catch {
return { changes: [], reason: '', mod_note: '' }
}
}
return logData.value.changes
})
const fetchLogDetails = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const data = await $fetch(`/api/admin/audit-logs/${logId.value}`)
logData.value = data
} catch (err) {
console.error('Failed to fetch audit log:', err)
errorMessage.value = err.data?.statusMessage || 'Failed to load the audit log entry.'
} finally {
isLoading.value = false
}
}
const handleLogout = () => logout()
const handleNavigate = (path) => navigateTo(path)
onMounted(async () => {
await initializeAuth({ redirectTo: '/oauth' })
if (isAuthenticated.value) {
await fetchLogDetails()
}
})
</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%; max-width: 900px; margin: 0 auto;">
<div class="panel-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
<span>AUDIT LOG #{{ logId }}</span>
<button
type="button"
class="btn-action secondary sm"
@click="handleNavigate('/audit/log')"
>
Back to All Logs
</button>
</div>
<div class="panel-body" style="padding: 1.5rem;">
<div v-if="isLoading" class="state-container">
<div class="spinner sm"></div>
<span>Loading log details...</span>
</div>
<div v-else-if="errorMessage" class="alert-error">
<p>{{ errorMessage }}</p>
<button class="btn-action secondary sm" @click="handleNavigate('/audit/log')">
Return to Audit Logs
</button>
</div>
<div v-else-if="logData" class="log-details-container">
<div class="summary-grid">
<div class="meta-card">
<span class="meta-label">Performed By</span>
<div class="user-block">
<img
2026-08-09 21:57:29 +02:00
:src="logData.editor_pid ? `https://mii.spbr.net/${logData.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
2026-08-05 06:36:58 +02:00
class="avatar"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<div class="user-info">
<span class="user-name">{{ logData.editor_sfid || 'System / Unknown' }}</span>
<span class="user-pid">PID: {{ logData.editor_pid || 'N/A' }}</span>
</div>
</div>
</div>
<div class="meta-card">
<span class="meta-label">Target User</span>
<div class="user-block">
<img
2026-08-09 21:57:29 +02:00
:src="logData.target_pid ? `https://mii.spbr.net/${logData.target_pid}/main.png` : '/img_unknown_MiiIcon.png'"
2026-08-05 06:36:58 +02:00
class="avatar"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<div class="user-info">
<span class="user-name">{{ logData.target_name }}</span>
<span class="user-pid">PID: {{ logData.target_pid }}</span>
</div>
</div>
</div>
<div class="meta-card">
<span class="meta-label">Timestamp</span>
<div class="timestamp-block">
<span class="time-value">{{ formatDate(logData.created_at) }}</span>
<span class="log-id-tag">Log ID: {{ logData.log_id || logData.id }}</span>
</div>
</div>
</div>
<div v-if="logData.ban_level !== undefined && logData.ban_level !== null" class="ban-banner">
<span class="ban-icon"></span>
<div>
<strong>ACCOUNT BAN ENFORCED</strong>
<p>Account level updated to Level {{ logData.ban_level }} ({{ ACCOUNT_LEVEL_NAMES[String(logData.ban_level)] || 'Banned' }})</p>
</div>
</div>
<div class="section-container">
<h3 class="section-title">Modified Attributes</h3>
<div v-if="parsedChangesPayload.changes.length > 0" class="diff-list">
<div
v-for="(c, idx) in parsedChangesPayload.changes"
:key="idx"
class="diff-card"
>
<div class="diff-header">
<span class="field-name">{{ FIELD_LABELS[c.field] || c.field }}</span>
</div>
<div class="diff-comparison">
<div class="diff-value old">
<span class="diff-tag">PREVIOUS</span>
<code>{{ formatValue(c.field, c.oldVal) }}</code>
</div>
<div class="diff-arrow"></div>
<div class="diff-value new">
<span class="diff-tag">UPDATED TO</span>
<code>{{ formatValue(c.field, c.newVal) }}</code>
</div>
</div>
</div>
</div>
<p v-else class="text-dim">No individual field diffs were recorded for this entry.</p>
</div>
<div v-if="parsedChangesPayload.reason || logData.reason" class="section-container">
<h3 class="section-title">Reason / Violation Category</h3>
<div class="text-card">
<p>{{ parsedChangesPayload.reason || logData.reason }}</p>
</div>
</div>
<div v-if="parsedChangesPayload.mod_note || logData.mod_note" class="section-container">
<h3 class="section-title">Moderator Note & Evidence</h3>
<div class="text-card highlight">
<p>{{ parsedChangesPayload.mod_note || logData.mod_note }}</p>
</div>
</div>
<div class="footer-actions">
<button
type="button"
class="btn-action secondary"
@click="handleNavigate('/audit/log')"
>
Back to Audit List
</button>
<NuxtLink
v-if="logData.target_pid"
:to="`/users/${logData.target_pid}`"
class="btn-action primary"
style="text-decoration: none;"
>
View Target Profile (PID: {{ logData.target_pid }})
</NuxtLink>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<style scoped>
.state-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
color: #8b949e;
gap: 0.75rem;
}
.alert-error {
background: #221515;
border: 1px solid #f85149;
color: #f85149;
padding: 1.25rem;
border-radius: 6px;
text-align: center;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.meta-card {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.meta-label {
font-size: 0.75rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.user-block {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: #161b22;
object-fit: contain;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 700;
color: #f0f6fc;
font-size: 0.95rem;
}
.user-pid {
font-size: 0.8rem;
color: #8b949e;
}
.timestamp-block {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.time-value {
color: #c9d1d9;
font-weight: 600;
font-size: 0.9rem;
}
.log-id-tag {
font-size: 0.8rem;
color: #8b949e;
font-family: monospace;
}
.ban-banner {
background: #2c0b0e;
border: 1px solid #f85149;
color: #ff7b72;
padding: 1rem 1.25rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.ban-icon {
font-size: 1.5rem;
}
.ban-banner p {
margin: 0.2rem 0 0 0;
font-size: 0.85rem;
color: #f0f6fc;
}
.section-container {
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.85rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.diff-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.diff-card {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0.85rem;
}
.diff-header {
margin-bottom: 0.5rem;
}
.field-name {
font-weight: 600;
color: #f0f6fc;
font-size: 0.9rem;
}
.diff-comparison {
display: flex;
align-items: center;
gap: 0.75rem;
}
.diff-value {
flex: 1;
background: #161b22;
padding: 0.5rem 0.75rem;
border-radius: 4px;
border: 1px solid #21262d;
}
.diff-value.old { border-color: #490202; }
.diff-value.new { border-color: #047205; }
.diff-tag {
display: block;
font-size: 0.65rem;
color: #8b949e;
margin-bottom: 0.2rem;
}
.diff-arrow {
color: #8b949e;
font-weight: bold;
}
.text-card {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 1rem;
}
.text-card.highlight {
border-color: #388bfd;
background: #0d1520;
}
.text-card p {
margin: 0;
color: #c9d1d9;
font-size: 0.95rem;
white-space: pre-wrap;
line-height: 1.5;
}
.text-dim {
color: #8b949e;
font-style: italic;
font-size: 0.9rem;
}
.footer-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid #30363d;
}
.btn-action {
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.btn-action.primary { background: #238636; border: 1px solid #2ea043; color: #fff; }
.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:hover { opacity: 0.85; }
</style>