bunch of shit
This commit is contained in:
parent
3cf2ec38aa
commit
0f577eef4d
22 changed files with 2889 additions and 37 deletions
|
|
@ -41,7 +41,7 @@ onMounted(async () => {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding-top: 5rem; /* Added padding-top */
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.app-viewport,
|
||||
|
|
@ -64,6 +64,6 @@ onMounted(async () => {
|
|||
|
||||
.footer-wrapper {
|
||||
width: 100%;
|
||||
margin-top: 8rem; /* Added margin-top */
|
||||
margin-top: 8rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -42,6 +42,13 @@ const emit = defineEmits(['logout', 'navigate'])
|
|||
@click="emit('navigate', '/search')">
|
||||
<span>Search Users</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-item"
|
||||
:class="{ 'active-nav': activeTab === 'audit' || activeTab === 'audit/log' }"
|
||||
@click="emit('navigate', '/audit/log')">
|
||||
<span>Audit Logs</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-item logout-btn" @click="emit('logout')">
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const userToken = ref(null)
|
||||
const refreshToken = ref(null)
|
||||
const userProfile = ref(null)
|
||||
const userRoles = ref([])
|
||||
const isAuthenticated = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
let sessionPollTimer = null
|
||||
|
||||
const extractRolesFromToken = (token) => {
|
||||
if (!token) return []
|
||||
try {
|
||||
const payloadBase64 = token.split('.')[1]
|
||||
const base64 = payloadBase64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
)
|
||||
const payload = JSON.parse(jsonPayload)
|
||||
|
||||
const realmRoles = payload?.realm_access?.roles || []
|
||||
const clientRoles = Object.values(payload?.resource_access || {})
|
||||
.flatMap(client => client.roles || [])
|
||||
const directRoles = payload?.roles || []
|
||||
|
||||
return [...realmRoles, ...clientRoles, ...directRoles].map(r => String(r).toLowerCase())
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse roles from token:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const getStoredRefreshToken = () => {
|
||||
if (refreshToken.value) {
|
||||
return refreshToken.value
|
||||
|
|
@ -37,6 +63,8 @@ export const useAuth = () => {
|
|||
|
||||
const setSession = (data) => {
|
||||
userToken.value = data.access_token
|
||||
userRoles.value = extractRolesFromToken(data.access_token)
|
||||
|
||||
if (data.refresh_token) {
|
||||
refreshToken.value = data.refresh_token
|
||||
if (import.meta.client) {
|
||||
|
|
@ -51,6 +79,7 @@ export const useAuth = () => {
|
|||
userToken.value = null
|
||||
refreshToken.value = null
|
||||
userProfile.value = null
|
||||
userRoles.value = []
|
||||
isAuthenticated.value = false
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem('keycloak_refresh_token')
|
||||
|
|
@ -69,6 +98,7 @@ export const useAuth = () => {
|
|||
|
||||
setSession(data)
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
|
||||
await fetchUserProfile()
|
||||
startLiveSessionPolling()
|
||||
|
||||
|
|
@ -123,9 +153,11 @@ export const useAuth = () => {
|
|||
|
||||
if (res.active) {
|
||||
isAuthenticated.value = true
|
||||
|
||||
if (!userProfile.value) {
|
||||
await fetchUserProfile()
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
return await silentRefresh()
|
||||
|
|
@ -144,6 +176,12 @@ export const useAuth = () => {
|
|||
await navigateTo(redirectTo, { replace })
|
||||
return false
|
||||
}
|
||||
|
||||
if (result.isFirstLogin) {
|
||||
await navigateTo('/welcome', { replace: true })
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +245,9 @@ export const useAuth = () => {
|
|||
|
||||
return {
|
||||
userToken,
|
||||
token: userToken,
|
||||
userProfile,
|
||||
userRoles,
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
exchangeCodeForToken,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
await initializeAuth({ redirectTo: '/oauth', replace: true })
|
||||
}
|
||||
|
||||
//whats the shape of Italy?
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/oauth', { replace: true })
|
||||
}
|
||||
|
|
|
|||
497
app/pages/audit/log/[logid]/index.vue
Normal file
497
app/pages/audit/log/[logid]/index.vue
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
<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
|
||||
:src="logData.editor_pid ? `https://mii.spfn.net/${logData.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
||||
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
|
||||
:src="logData.target_pid ? `https://mii.spfn.net/${logData.target_pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
||||
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>
|
||||
561
app/pages/audit/log/index.vue
Normal file
561
app/pages/audit/log/index.vue
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
<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>
|
||||
|
|
@ -12,9 +12,55 @@ const {
|
|||
|
||||
const route = useRoute()
|
||||
|
||||
const recentLogins = ref([
|
||||
{ user: 'admin_user', time: '2 mins ago', ip: '192.168.1.10' }
|
||||
])
|
||||
const recentActions = ref([])
|
||||
const isLoadingActions = ref(true)
|
||||
|
||||
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 formatDate = (isoStr) => {
|
||||
if (!isoStr) return 'N/A'
|
||||
try {
|
||||
return new Date(isoStr).toLocaleString('en-US', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
} catch {
|
||||
return isoStr
|
||||
}
|
||||
}
|
||||
|
||||
const parseLogChanges = (rawChanges) => {
|
||||
if (!rawChanges) return { changes: [] }
|
||||
if (typeof rawChanges === 'string') {
|
||||
try {
|
||||
return JSON.parse(rawChanges)
|
||||
} catch {
|
||||
return { changes: [] }
|
||||
}
|
||||
}
|
||||
return rawChanges
|
||||
}
|
||||
|
||||
const fetchRecentActions = async () => {
|
||||
isLoadingActions.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/audit-logs?limit=3&page=1')
|
||||
recentActions.value = response.logs || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch recent actions:', err)
|
||||
} finally {
|
||||
isLoadingActions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
|
|
@ -26,6 +72,9 @@ const handleNavigate = (path) => {
|
|||
|
||||
onMounted(async () => {
|
||||
await initializeAuth({ code: route.query.code, redirectTo: '/oauth' })
|
||||
if (isAuthenticated.value) {
|
||||
await fetchRecentActions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -68,23 +117,76 @@ onMounted(async () => {
|
|||
|
||||
<div class="logins-right-col">
|
||||
<div class="logins-card">
|
||||
<div class="logins-header archivo-black">
|
||||
RECENT LOGINS
|
||||
<div class="logins-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>RECENT ACTIONS</span>
|
||||
<NuxtLink to="/audit/log" class="view-all-link">View All →</NuxtLink>
|
||||
</div>
|
||||
<ul class="logins-list">
|
||||
<li v-for="(log, idx) in recentLogins" :key="idx" class="login-item">
|
||||
<div class="login-user">{{ log.user }}</div>
|
||||
<div class="login-details">
|
||||
<span>{{ log.ip }}</span> •
|
||||
<span class="login-time">{{ log.time }}</span>
|
||||
|
||||
<div v-if="isLoadingActions" class="loading-state">
|
||||
<div class="spinner sm"></div>
|
||||
<span>Loading recent actions...</span>
|
||||
</div>
|
||||
|
||||
<ul v-else-if="recentActions.length > 0" class="logins-list">
|
||||
<li
|
||||
v-for="log in recentActions"
|
||||
:key="log.log_id || log.id"
|
||||
class="login-item action-item"
|
||||
@click="handleNavigate(`/audit/log/${log.log_id || log.id}`)"
|
||||
>
|
||||
<div class="action-top">
|
||||
<div class="user-block">
|
||||
<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 class="editor-name">{{ log.editor_sfid || 'System' }}</span>
|
||||
</div>
|
||||
<span class="action-time">{{ formatDate(log.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-summary">
|
||||
<span class="target-name">Modified {{ log.target_name }}</span>
|
||||
|
||||
<span v-if="log.ban_level !== undefined && log.ban_level !== null" class="badge-ban">
|
||||
Banned
|
||||
</span>
|
||||
<span v-else class="changes-tags">
|
||||
<span
|
||||
v-for="change in parseLogChanges(log.changes).changes.slice(0, 2)"
|
||||
:key="change.field"
|
||||
class="change-chip"
|
||||
>
|
||||
{{ FIELD_LABELS[change.field] || change.field }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>No recent actions logged.</p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action secondary sm"
|
||||
style="width: 100%;"
|
||||
@click="handleNavigate('/audit/log')"
|
||||
>
|
||||
Go to Audit Log Center
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ const isSearching = ref(false)
|
|||
const hasSearched = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// pagination state
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(0)
|
||||
const totalResults = ref(0)
|
||||
|
|
@ -30,6 +29,11 @@ const handleNavigate = (path) => {
|
|||
navigateTo(path)
|
||||
}
|
||||
|
||||
const navigateToUser = (pid) => {
|
||||
if (!pid) return
|
||||
navigateTo(`/users/${pid}`)
|
||||
}
|
||||
|
||||
const executeSearch = async (page = 1) => {
|
||||
const query = searchQuery.value.trim()
|
||||
if (!query) return
|
||||
|
|
@ -133,7 +137,9 @@ onMounted(async () => {
|
|||
<div
|
||||
v-for="user in searchResults"
|
||||
:key="user.id"
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px;"
|
||||
class="user-card"
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease;"
|
||||
@click="navigateToUser(user.pid)"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 0.85rem;">
|
||||
<img
|
||||
|
|
@ -152,8 +158,16 @@ onMounted(async () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
|
||||
{{ user.email }}
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style="padding: 0.35rem 0.75rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.8rem; font-weight: 600; pointer-events: none;"
|
||||
>
|
||||
View →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,4 +207,11 @@ onMounted(async () => {
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card:hover {
|
||||
background: #161b22 !important;
|
||||
border-color: #58a6ff !important;
|
||||
}
|
||||
</style>
|
||||
665
app/pages/users/[pid]/index.vue
Normal file
665
app/pages/users/[pid]/index.vue
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth.js'
|
||||
|
||||
const route = useRoute()
|
||||
const pid = route.params.pid
|
||||
|
||||
const {
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
userProfile,
|
||||
userRoles,
|
||||
token,
|
||||
initializeAuth,
|
||||
logout
|
||||
} = useAuth()
|
||||
|
||||
const user = ref(null)
|
||||
const editForm = ref({})
|
||||
const isEditing = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const violationCategories = ref([])
|
||||
const selectedCategory = ref('')
|
||||
const selectedGameId = ref('')
|
||||
const selectedSubRule = ref('')
|
||||
const modNote = ref('')
|
||||
const changeReasonText = ref('')
|
||||
|
||||
const validCountries = [
|
||||
"GB", "US", "IT", "NL", "DE", "CA", "FR", "HU", "CR",
|
||||
"AU", "BR", "RO", "CL", "MX", "RU", "ES", "JP", "CZ",
|
||||
"PT", "MT", "AR", "SE", "PL", "IE", "BE", "HT", "NO",
|
||||
"FI", "GR", "BO", "AT", "VE", "PA", "PE", "GF", "SA",
|
||||
"CO", "LT", "NA", "CH", "CY", "RS", "KY", "GP", "DK",
|
||||
"KR", "LU", "SV", "VA", "GT", "SK", "HR", "ZA", "DO",
|
||||
"UY", "LV", "HN", "JM", "TR", "IN", "ER", "AW", "NZ",
|
||||
"EC", "TW", "EE", "CN", "SI", "AI", "BG", "NI", "IS",
|
||||
"MQ", "BZ", "BA", "MY", "AZ", "ZW", "AL", "IM", "VG",
|
||||
"VI", "BM", "GY", "SR", "MS", "TC", "BB", "TT"
|
||||
]
|
||||
|
||||
const validLanguages = [
|
||||
"en", "it", "de", "fr", "es", "us", "pt", "ru", "ja", "nl", "ko", "zh", "tw"
|
||||
]
|
||||
|
||||
const accountLevels = {
|
||||
'-3': { title: 'Omey Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||||
'-2': { title: 'Inf Eula Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||||
'-1': { title: 'Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||||
'0': { title: 'Member', realmRole: 'role-member', textColor: '#fff', bgStart: '#047205', bgEnd: '#00a003' },
|
||||
'1': { title: 'Tester', realmRole: 'role-tester', textColor: '#fff', bgStart: '#0c98a2', bgEnd: '#01c9d7' },
|
||||
'2': { title: 'Moderator', realmRole: 'role-moderator', textColor: '#000', bgStart: '#00dc04', bgEnd: '#067708' },
|
||||
'3': { title: 'Admin / Developer', realmRole: 'role-admin', textColor: '#fff', bgStart: '#920606', bgEnd: '#c80404' },
|
||||
'4': { title: 'Manager', realmRole: 'role-manager', textColor: '#fff', bgStart: '#0830b4', bgEnd: '#1e51f8' }
|
||||
}
|
||||
|
||||
const FIELD_ROLES = {
|
||||
email: ['can-edit-email'],
|
||||
account_level: ['can-edit-account_level'],
|
||||
country: ['can-edit-country'],
|
||||
language: ['can-edit-language'],
|
||||
timezone: ['can-edit-timezone'],
|
||||
gender: ['can-edit-gender'],
|
||||
marketing_allowed: ['can-edit-marketing_allowed'],
|
||||
off_device_allowed: ['can-edit-off_device_allowed']
|
||||
}
|
||||
|
||||
const isNewBanSelected = computed(() => {
|
||||
const originalLevel = Number(user.value?.account_level)
|
||||
const newLevel = Number(editForm.value.account_level)
|
||||
return originalLevel >= 0 && newLevel < 0
|
||||
})
|
||||
|
||||
const activeCategoryObj = computed(() => {
|
||||
return violationCategories.value.find(c => c.id === selectedCategory.value)
|
||||
})
|
||||
|
||||
const activeGameObj = computed(() => {
|
||||
if (activeCategoryObj.value?.type !== 'game') return null
|
||||
return activeCategoryObj.value?.games?.find(g => g.id === selectedGameId.value)
|
||||
})
|
||||
|
||||
const availableSubRules = computed(() => {
|
||||
if (!activeCategoryObj.value) return []
|
||||
if (activeCategoryObj.value.type === 'general') return activeCategoryObj.value.rules || []
|
||||
if (activeCategoryObj.value.type === 'game') return activeGameObj.value?.rules || []
|
||||
return []
|
||||
})
|
||||
|
||||
const constructedBanReason = computed(() => {
|
||||
if (!isNewBanSelected.value) return changeReasonText.value
|
||||
if (!activeCategoryObj.value) return ''
|
||||
|
||||
let reasonStr = activeCategoryObj.value.label
|
||||
|
||||
if (activeCategoryObj.value.type === 'game' && activeGameObj.value) {
|
||||
reasonStr += ` [Game: ${activeGameObj.value.name}]`
|
||||
}
|
||||
|
||||
if (selectedSubRule.value) {
|
||||
reasonStr += ` - ${selectedSubRule.value}`
|
||||
}
|
||||
|
||||
return reasonStr
|
||||
})
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const rulesData = await $fetch('/api/admin/rules')
|
||||
violationCategories.value = rulesData
|
||||
} catch (err) {
|
||||
console.error('Failed to load violation rules:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCountry = (countryCode) => {
|
||||
if (!countryCode) return 'N/A'
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' }).of(countryCode.toUpperCase())
|
||||
} catch {
|
||||
return countryCode
|
||||
}
|
||||
}
|
||||
|
||||
const formatLanguage = (langCode) => {
|
||||
if (!langCode) return 'N/A'
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'language' }).of(langCode.toLowerCase())
|
||||
} catch {
|
||||
return langCode
|
||||
}
|
||||
}
|
||||
|
||||
const formatGender = (genderCode) => {
|
||||
if (!genderCode) return 'N/A'
|
||||
const code = String(genderCode).toUpperCase()
|
||||
return code === 'M' ? 'Male' : code === 'F' ? 'Female' : genderCode
|
||||
}
|
||||
|
||||
const formatTimezone = (tzString) => {
|
||||
if (!tzString) return 'N/A'
|
||||
try {
|
||||
const cityName = tzString.split('/').pop()?.replace(/_/g, ' ') || tzString
|
||||
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tzString, timeZoneName: 'shortOffset' })
|
||||
const parts = formatter.formatToParts(new Date())
|
||||
const offset = parts.find(p => p.type === 'timeZoneName')?.value || ''
|
||||
return `${cityName} (${offset.replace('GMT', 'UTC')})`
|
||||
} catch {
|
||||
return tzString.replace(/_/g, ' ')
|
||||
}
|
||||
}
|
||||
|
||||
const canEditField = (fieldName) => {
|
||||
if (!userRoles.value || !userRoles.value.length) return false
|
||||
const allowedRoles = FIELD_ROLES[fieldName]
|
||||
if (!allowedRoles) return false
|
||||
return userRoles.value.some(role => allowedRoles.includes(String(role).toLowerCase()))
|
||||
}
|
||||
|
||||
const canEditProfile = computed(() => {
|
||||
if (!userRoles.value || !userRoles.value.length) return false
|
||||
return Object.keys(FIELD_ROLES).some(field => canEditField(field))
|
||||
})
|
||||
|
||||
const userAccountLevel = computed(() => {
|
||||
if (!user.value || user.value.account_level === null || user.value.account_level === undefined) return null
|
||||
const levelKey = String(user.value.account_level)
|
||||
return accountLevels[levelKey] || {
|
||||
title: `Level ${user.value.account_level}`,
|
||||
textColor: '#fff',
|
||||
bgStart: '#30363d',
|
||||
bgEnd: '#21262d'
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogout = () => logout()
|
||||
const handleNavigate = (path) => navigateTo(path)
|
||||
|
||||
const fetchUserData = async () => {
|
||||
if (!isEditing.value) isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const data = await $fetch(`/api/admin/users/${pid}`)
|
||||
user.value = data
|
||||
if (!isEditing.value) {
|
||||
editForm.value = { ...data }
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err.data?.statusMessage || 'Failed to load user profile.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
selectedCategory.value = ''
|
||||
selectedGameId.value = ''
|
||||
selectedSubRule.value = ''
|
||||
changeReasonText.value = ''
|
||||
modNote.value = ''
|
||||
editForm.value = { ...user.value }
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
isSaving.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
if (isNewBanSelected.value) {
|
||||
if (!selectedCategory.value) {
|
||||
errorMessage.value = 'Please select a Violation Category.'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
if (activeCategoryObj.value?.type === 'game' && !selectedGameId.value) {
|
||||
errorMessage.value = 'Please select the Target Game for this violation.'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
if (availableSubRules.value.length > 0 && !selectedSubRule.value) {
|
||||
errorMessage.value = 'Please select a Specific Rule Violated.'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
change_reason: constructedBanReason.value,
|
||||
mod_note: modNote.value
|
||||
}
|
||||
|
||||
for (const field of Object.keys(FIELD_ROLES)) {
|
||||
if (canEditField(field) && editForm.value[field] !== undefined) {
|
||||
payload[field] = editForm.value[field]
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = token?.value || ''
|
||||
|
||||
await $fetch(`/api/admin/users/${pid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: accessToken ? `Bearer ${accessToken}` : ''
|
||||
},
|
||||
body: payload
|
||||
})
|
||||
|
||||
await fetchUserData()
|
||||
|
||||
isEditing.value = false
|
||||
successMessage.value = 'Profile updated successfully!'
|
||||
setTimeout(() => { successMessage.value = '' }, 4000)
|
||||
} catch (err) {
|
||||
console.error('Update profile error:', err)
|
||||
errorMessage.value = err.data?.statusMessage || err.message || 'Failed to update profile.'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(userProfile, async (newProfile) => {
|
||||
if (newProfile && isAuthenticated.value) {
|
||||
await fetchUserData()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await initializeAuth({ redirectTo: '/oauth' })
|
||||
if (isAuthenticated.value) {
|
||||
await fetchUserData()
|
||||
await fetchRules()
|
||||
}
|
||||
})
|
||||
</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="search"
|
||||
@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>USER PROFILE</span>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
v-if="canEditProfile && !isEditing"
|
||||
type="button"
|
||||
class="btn-action primary"
|
||||
@click="startEditing"
|
||||
>
|
||||
✎ Edit Profile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action secondary"
|
||||
@click="handleNavigate('/search')"
|
||||
>
|
||||
← Back to Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body" style="padding: 1.5rem;">
|
||||
<div v-if="isLoading" style="text-align: center; padding: 2rem; color: #8b949e;">
|
||||
<div class="spinner sm" style="margin: 0 auto 0.5rem auto;"></div>
|
||||
Loading profile details...
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" style="text-align: center; padding: 1rem; color: #f85149; background: #221515; border-radius: 6px; margin-bottom: 1rem;">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" style="text-align: center; padding: 1rem; color: #3fb950; background: #0d2818; border-radius: 6px; margin-bottom: 1rem;">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="user" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.25rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 1.25rem;">
|
||||
<img
|
||||
:src="user.pid ? `https://mii.spfn.net/${user.pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
||||
:alt="user.username || user.sfid || 'Mii Avatar'"
|
||||
style="width: 72px; height: 72px; border-radius: 50%; background: #161b22; object-fit: contain; flex-shrink: 0;"
|
||||
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
|
||||
/>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f0f6fc; margin: 0;">
|
||||
{{ user.sfid || user.username || 'No Username' }}
|
||||
</h2>
|
||||
<span style="font-size: 0.85rem; color: #8b949e;">PID: {{ user.pid }}</span>
|
||||
<span v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">Email: {{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userAccountLevel">
|
||||
<span
|
||||
class="role-badge"
|
||||
:style="{
|
||||
color: userAccountLevel.textColor,
|
||||
background: `linear-gradient(135deg, ${userAccountLevel.bgStart}, ${userAccountLevel.bgEnd})`
|
||||
}"
|
||||
>
|
||||
{{ userAccountLevel.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditing" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem;">
|
||||
<div class="info-card">
|
||||
<span class="info-label">Account Level</span>
|
||||
<span class="info-value">{{ userAccountLevel ? userAccountLevel.title : 'N/A' }} ({{ user.account_level ?? 'N/A' }})</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Country</span>
|
||||
<span class="info-value">{{ formatCountry(user.country) }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Language</span>
|
||||
<span class="info-value">{{ formatLanguage(user.language) }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Timezone</span>
|
||||
<span class="info-value">{{ formatTimezone(user.timezone) }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Gender</span>
|
||||
<span class="info-value">{{ formatGender(user.gender) }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Email Verified Since</span>
|
||||
<span class="info-value">{{ user.email_verified_since ? new Date(user.email_verified_since).toLocaleString() : 'Unverified' }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Marketing Allowed</span>
|
||||
<span class="info-value">{{ user.marketing_allowed ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="info-label">Off-Device Allowed</span>
|
||||
<span class="info-value">{{ user.off_device_allowed ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="saveChanges" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem;">
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('email') }">
|
||||
<label class="info-label">Email</label>
|
||||
<input
|
||||
v-model="editForm.email"
|
||||
type="email"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('email')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('account_level') }">
|
||||
<label class="info-label">Account Level</label>
|
||||
<select
|
||||
v-model.number="editForm.account_level"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('account_level')"
|
||||
>
|
||||
<option v-for="(val, key) in accountLevels" :key="key" :value="Number(key)">
|
||||
{{ val.title }} ({{ key }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('country') }">
|
||||
<label class="info-label">Country</label>
|
||||
<select
|
||||
v-model="editForm.country"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('country')"
|
||||
>
|
||||
<option v-for="code in validCountries" :key="code" :value="code">
|
||||
{{ formatCountry(code) }} ({{ code }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('language') }">
|
||||
<label class="info-label">Language</label>
|
||||
<select
|
||||
v-model="editForm.language"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('language')"
|
||||
>
|
||||
<option v-for="lang in validLanguages" :key="lang" :value="lang">
|
||||
{{ formatLanguage(lang) }} ({{ lang }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('timezone') }">
|
||||
<label class="info-label">Timezone</label>
|
||||
<input
|
||||
v-model="editForm.timezone"
|
||||
type="text"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('timezone')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('gender') }">
|
||||
<label class="info-label">Gender</label>
|
||||
<select
|
||||
v-model="editForm.gender"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('gender')"
|
||||
>
|
||||
<option value="M">Male (M)</option>
|
||||
<option value="F">Female (F)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('marketing_allowed') }">
|
||||
<label class="info-label">Marketing Allowed</label>
|
||||
<select
|
||||
v-model="editForm.marketing_allowed"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('marketing_allowed')"
|
||||
>
|
||||
<option :value="true">Yes</option>
|
||||
<option :value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-card" :class="{ 'disabled-card': !canEditField('off_device_allowed') }">
|
||||
<label class="info-label">Off-Device Allowed</label>
|
||||
<select
|
||||
v-model="editForm.off_device_allowed"
|
||||
class="input-field"
|
||||
:disabled="!canEditField('off_device_allowed')"
|
||||
>
|
||||
<option :value="true">Yes</option>
|
||||
<option :value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem; background: #0d1117; border: 1px solid #30363d; padding: 1rem; border-radius: 6px;">
|
||||
|
||||
<template v-if="isNewBanSelected">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<label class="info-label">Violation Category *</label>
|
||||
<select v-model="selectedCategory" class="input-field" required @change="selectedGameId = ''; selectedSubRule = ''">
|
||||
<option value="" disabled>Select a rule category...</option>
|
||||
<option v-for="cat in violationCategories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="activeCategoryObj?.type === 'game'" style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<label class="info-label">Target Game *</label>
|
||||
<select v-model="selectedGameId" class="input-field" required @change="selectedSubRule = ''">
|
||||
<option value="" disabled>Select game...</option>
|
||||
<option v-for="game in activeCategoryObj.games" :key="game.id" :value="game.id">
|
||||
{{ game.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div v-if="activeGameObj" style="display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem; background: #161b22; padding: 0.5rem; border-radius: 6px; border: 1px solid #30363d;">
|
||||
<img v-if="activeGameObj.iconUrl" :src="activeGameObj.iconUrl" :alt="activeGameObj.name" style="width: 32px; height: 32px; object-fit: contain; border-radius: 4px;" />
|
||||
<span style="font-weight: 600; font-size: 0.9rem; color: #f0f6fc;">{{ activeGameObj.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="availableSubRules.length > 0" style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<label class="info-label">Specific Rule Violated *</label>
|
||||
<select v-model="selectedSubRule" class="input-field" required>
|
||||
<option value="" disabled>Select specific rule...</option>
|
||||
<option v-for="rule in availableSubRules" :key="rule" :value="rule">
|
||||
{{ rule }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<label class="info-label">Moderator Note & Evidence (Optional)</label>
|
||||
<textarea
|
||||
v-model="modNote"
|
||||
class="input-field"
|
||||
rows="3"
|
||||
placeholder="Provide optional details and evidence links (e.g., ticket #, screenshots, logs)..."
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<label class="info-label">Reason for Change</label>
|
||||
<input
|
||||
v-model="changeReasonText"
|
||||
type="text"
|
||||
class="input-field"
|
||||
placeholder="Optional reason for making these changes..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem;">
|
||||
<button type="button" class="btn-action secondary" @click="isEditing = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-action primary" :disabled="isSaving">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.disabled-card {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #f0f6fc;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.input-field:disabled {
|
||||
background: #0d1117;
|
||||
color: #484f58;
|
||||
border-color: #21262d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-action.primary {
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-action.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { userToken, isAuthenticated, initializeAuth } = useAuth()
|
||||
|
||||
const sfidInput = ref('')
|
||||
|
|
@ -13,7 +14,17 @@ const errorMessage = ref('')
|
|||
|
||||
onMounted(async () => {
|
||||
const isReady = await initializeAuth({ redirectTo: '/oauth', replace: true })
|
||||
if (isReady && isAuthenticated.value) {
|
||||
|
||||
if (!isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
await navigateTo('/oauth', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (route.query.redirect === 'home') {
|
||||
await navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue