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>
|