bunch of shit
This commit is contained in:
parent
3cf2ec38aa
commit
0f577eef4d
22 changed files with 2889 additions and 37 deletions
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "public/policy"]
|
||||||
|
path = public/policy
|
||||||
|
url = https://git.spbr.net/spacebar/policy.git
|
||||||
|
branch = main
|
||||||
|
|
@ -41,7 +41,7 @@ onMounted(async () => {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-top: 5rem; /* Added padding-top */
|
padding-top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-viewport,
|
.app-viewport,
|
||||||
|
|
@ -64,6 +64,6 @@ onMounted(async () => {
|
||||||
|
|
||||||
.footer-wrapper {
|
.footer-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8rem; /* Added margin-top */
|
margin-top: 8rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -42,6 +42,13 @@ const emit = defineEmits(['logout', 'navigate'])
|
||||||
@click="emit('navigate', '/search')">
|
@click="emit('navigate', '/search')">
|
||||||
<span>Search Users</span>
|
<span>Search Users</span>
|
||||||
</button>
|
</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')">
|
<button type="button" class="nav-item logout-btn" @click="emit('logout')">
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,39 @@
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const userToken = ref(null)
|
const userToken = ref(null)
|
||||||
const refreshToken = ref(null)
|
const refreshToken = ref(null)
|
||||||
const userProfile = ref(null)
|
const userProfile = ref(null)
|
||||||
|
const userRoles = ref([])
|
||||||
const isAuthenticated = ref(false)
|
const isAuthenticated = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
let sessionPollTimer = null
|
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 = () => {
|
const getStoredRefreshToken = () => {
|
||||||
if (refreshToken.value) {
|
if (refreshToken.value) {
|
||||||
return refreshToken.value
|
return refreshToken.value
|
||||||
|
|
@ -37,6 +63,8 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const setSession = (data) => {
|
const setSession = (data) => {
|
||||||
userToken.value = data.access_token
|
userToken.value = data.access_token
|
||||||
|
userRoles.value = extractRolesFromToken(data.access_token)
|
||||||
|
|
||||||
if (data.refresh_token) {
|
if (data.refresh_token) {
|
||||||
refreshToken.value = data.refresh_token
|
refreshToken.value = data.refresh_token
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
|
|
@ -51,6 +79,7 @@ export const useAuth = () => {
|
||||||
userToken.value = null
|
userToken.value = null
|
||||||
refreshToken.value = null
|
refreshToken.value = null
|
||||||
userProfile.value = null
|
userProfile.value = null
|
||||||
|
userRoles.value = []
|
||||||
isAuthenticated.value = false
|
isAuthenticated.value = false
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.removeItem('keycloak_refresh_token')
|
localStorage.removeItem('keycloak_refresh_token')
|
||||||
|
|
@ -69,6 +98,7 @@ export const useAuth = () => {
|
||||||
|
|
||||||
setSession(data)
|
setSession(data)
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
|
|
||||||
await fetchUserProfile()
|
await fetchUserProfile()
|
||||||
startLiveSessionPolling()
|
startLiveSessionPolling()
|
||||||
|
|
||||||
|
|
@ -123,9 +153,11 @@ export const useAuth = () => {
|
||||||
|
|
||||||
if (res.active) {
|
if (res.active) {
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
|
|
||||||
if (!userProfile.value) {
|
if (!userProfile.value) {
|
||||||
await fetchUserProfile()
|
await fetchUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return await silentRefresh()
|
return await silentRefresh()
|
||||||
|
|
@ -144,6 +176,12 @@ export const useAuth = () => {
|
||||||
await navigateTo(redirectTo, { replace })
|
await navigateTo(redirectTo, { replace })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.isFirstLogin) {
|
||||||
|
await navigateTo('/welcome', { replace: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +245,9 @@ export const useAuth = () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userToken,
|
userToken,
|
||||||
|
token: userToken,
|
||||||
userProfile,
|
userProfile,
|
||||||
|
userRoles,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
exchangeCodeForToken,
|
exchangeCodeForToken,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
await initializeAuth({ redirectTo: '/oauth', replace: true })
|
await initializeAuth({ redirectTo: '/oauth', replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
//whats the shape of Italy?
|
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
return navigateTo('/oauth', { replace: true })
|
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 route = useRoute()
|
||||||
|
|
||||||
const recentLogins = ref([
|
const recentActions = ref([])
|
||||||
{ user: 'admin_user', time: '2 mins ago', ip: '192.168.1.10' }
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
|
|
@ -26,6 +72,9 @@ const handleNavigate = (path) => {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initializeAuth({ code: route.query.code, redirectTo: '/oauth' })
|
await initializeAuth({ code: route.query.code, redirectTo: '/oauth' })
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
await fetchRecentActions()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -68,23 +117,76 @@ onMounted(async () => {
|
||||||
|
|
||||||
<div class="logins-right-col">
|
<div class="logins-right-col">
|
||||||
<div class="logins-card">
|
<div class="logins-card">
|
||||||
<div class="logins-header archivo-black">
|
<div class="logins-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
RECENT LOGINS
|
<span>RECENT ACTIONS</span>
|
||||||
|
<NuxtLink to="/audit/log" class="view-all-link">View All →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<ul class="logins-list">
|
|
||||||
<li v-for="(log, idx) in recentLogins" :key="idx" class="login-item">
|
<div v-if="isLoadingActions" class="loading-state">
|
||||||
<div class="login-user">{{ log.user }}</div>
|
<div class="spinner sm"></div>
|
||||||
<div class="login-details">
|
<span>Loading recent actions...</span>
|
||||||
<span>{{ log.ip }}</span> •
|
</div>
|
||||||
<span class="login-time">{{ log.time }}</span>
|
|
||||||
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const isSearching = ref(false)
|
||||||
const hasSearched = ref(false)
|
const hasSearched = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
// pagination state
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const totalPages = ref(0)
|
const totalPages = ref(0)
|
||||||
const totalResults = ref(0)
|
const totalResults = ref(0)
|
||||||
|
|
@ -30,6 +29,11 @@ const handleNavigate = (path) => {
|
||||||
navigateTo(path)
|
navigateTo(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigateToUser = (pid) => {
|
||||||
|
if (!pid) return
|
||||||
|
navigateTo(`/users/${pid}`)
|
||||||
|
}
|
||||||
|
|
||||||
const executeSearch = async (page = 1) => {
|
const executeSearch = async (page = 1) => {
|
||||||
const query = searchQuery.value.trim()
|
const query = searchQuery.value.trim()
|
||||||
if (!query) return
|
if (!query) return
|
||||||
|
|
@ -133,7 +137,9 @@ onMounted(async () => {
|
||||||
<div
|
<div
|
||||||
v-for="user in searchResults"
|
v-for="user in searchResults"
|
||||||
:key="user.id"
|
: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;">
|
<div style="display: flex; align-items: center; gap: 0.85rem;">
|
||||||
<img
|
<img
|
||||||
|
|
@ -152,8 +158,16 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
{{ user.email }}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,4 +207,11 @@ onMounted(async () => {
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const { userToken, isAuthenticated, initializeAuth } = useAuth()
|
const { userToken, isAuthenticated, initializeAuth } = useAuth()
|
||||||
|
|
||||||
const sfidInput = ref('')
|
const sfidInput = ref('')
|
||||||
|
|
@ -13,7 +14,17 @@ const errorMessage = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const isReady = await initializeAuth({ redirectTo: '/oauth', replace: true })
|
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 })
|
await navigateTo('/', { replace: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
77
bun.lock
77
bun.lock
|
|
@ -6,6 +6,8 @@
|
||||||
"name": "mod-portal-v2",
|
"name": "mod-portal-v2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^4.10.0",
|
"@nuxt/ui": "^4.10.0",
|
||||||
|
"discord.js": "^14.27.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"keycloak-js": "^26.2.4",
|
"keycloak-js": "^26.2.4",
|
||||||
"nuxt": "^4.5.0",
|
"nuxt": "^4.5.0",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
|
|
@ -15,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/simple-icons": "^1.2.92",
|
"@iconify-json/simple-icons": "^1.2.92",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -91,6 +94,18 @@
|
||||||
|
|
||||||
"@devframes/json-render": ["@devframes/json-render@0.7.14", "", { "dependencies": { "@json-render/core": "^0.19.0", "nostics": "^1.2.0", "zod": "^4.4.3" }, "peerDependencies": { "@devframes/hub": "0.7.14", "devframe": "0.7.14" }, "optionalPeers": ["@devframes/hub"] }, "sha512-Bo+IiRpEdceQjLr/tcFNXFYlBkSeUv7PWsFc4Fu5PLUyxnnqciLVyFjZz6IlEtKrBIA667L4QbQe5ZExlPhlPA=="],
|
"@devframes/json-render": ["@devframes/json-render@0.7.14", "", { "dependencies": { "@json-render/core": "^0.19.0", "nostics": "^1.2.0", "zod": "^4.4.3" }, "peerDependencies": { "@devframes/hub": "0.7.14", "devframe": "0.7.14" }, "optionalPeers": ["@devframes/hub"] }, "sha512-Bo+IiRpEdceQjLr/tcFNXFYlBkSeUv7PWsFc4Fu5PLUyxnnqciLVyFjZz6IlEtKrBIA667L4QbQe5ZExlPhlPA=="],
|
||||||
|
|
||||||
|
"@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="],
|
||||||
|
|
||||||
|
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||||
|
|
||||||
|
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
||||||
|
|
||||||
|
"@discordjs/rest": ["@discordjs/rest@2.6.3", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.50", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg=="],
|
||||||
|
|
||||||
|
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||||
|
|
||||||
|
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||||
|
|
||||||
"@dxup/nuxt": ["@dxup/nuxt@0.5.4", "", { "dependencies": { "@dxup/unimport": "^0.1.2", "@nuxt/kit": "^4.5.0", "@vue/compiler-dom": "^3.5.40", "chokidar": "^5.0.0", "knitwork": "^1.3.0", "magic-string": "^1.1.0", "pathe": "^2.0.3", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0" } }, "sha512-ERtH9CAO+gBb4x5sWC0pmAdBf3vaqhCw1OHyS6i4/f9MFQDcIo5qrQggpnxBQmH8qMMFfB+fhTbWqKL+nCIxQA=="],
|
"@dxup/nuxt": ["@dxup/nuxt@0.5.4", "", { "dependencies": { "@dxup/unimport": "^0.1.2", "@nuxt/kit": "^4.5.0", "@vue/compiler-dom": "^3.5.40", "chokidar": "^5.0.0", "knitwork": "^1.3.0", "magic-string": "^1.1.0", "pathe": "^2.0.3", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0" } }, "sha512-ERtH9CAO+gBb4x5sWC0pmAdBf3vaqhCw1OHyS6i4/f9MFQDcIo5qrQggpnxBQmH8qMMFfB+fhTbWqKL+nCIxQA=="],
|
||||||
|
|
||||||
"@dxup/unimport": ["@dxup/unimport@0.1.2", "", {}, "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ=="],
|
"@dxup/unimport": ["@dxup/unimport@0.1.2", "", {}, "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ=="],
|
||||||
|
|
@ -389,6 +404,12 @@
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="],
|
||||||
|
|
||||||
|
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||||
|
|
||||||
|
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||||
|
|
||||||
|
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
|
||||||
|
|
||||||
"@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="],
|
"@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="],
|
||||||
|
|
||||||
"@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="],
|
"@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="],
|
||||||
|
|
@ -525,10 +546,18 @@
|
||||||
|
|
||||||
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
|
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
|
||||||
|
|
||||||
|
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
|
||||||
|
|
||||||
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@unhead/bundler": ["@unhead/bundler@3.2.3", "", { "dependencies": { "@vitejs/devtools-kit": "^0.4.1", "magic-string": "^1.0.0", "oxc-parser": "^0.140.0", "oxc-walker": "^1.0.0", "ufo": "^1.6.4", "unplugin": "^3.3.0" }, "peerDependencies": { "@unhead/cli": "^3.2.3", "esbuild": ">=0.17.0", "lightningcss": ">=1.20.0", "rolldown": ">=1.0.0-beta.0", "unhead": "^3.2.3", "vite": ">=6.4.2", "webpack": ">=5.0.0" }, "optionalPeers": ["@unhead/cli", "esbuild", "lightningcss", "rolldown", "vite", "webpack"] }, "sha512-fL1LRdTyPYe9dzenElL96yaFW0LcXTUl1RSa5a2ni0n/GxMtvioPwz9y/fD4JnbVG//Kwn3YCv1jfUmlhcoW4g=="],
|
"@unhead/bundler": ["@unhead/bundler@3.2.3", "", { "dependencies": { "@vitejs/devtools-kit": "^0.4.1", "magic-string": "^1.0.0", "oxc-parser": "^0.140.0", "oxc-walker": "^1.0.0", "ufo": "^1.6.4", "unplugin": "^3.3.0" }, "peerDependencies": { "@unhead/cli": "^3.2.3", "esbuild": ">=0.17.0", "lightningcss": ">=1.20.0", "rolldown": ">=1.0.0-beta.0", "unhead": "^3.2.3", "vite": ">=6.4.2", "webpack": ">=5.0.0" }, "optionalPeers": ["@unhead/cli", "esbuild", "lightningcss", "rolldown", "vite", "webpack"] }, "sha512-fL1LRdTyPYe9dzenElL96yaFW0LcXTUl1RSa5a2ni0n/GxMtvioPwz9y/fD4JnbVG//Kwn3YCv1jfUmlhcoW4g=="],
|
||||||
|
|
||||||
"@unhead/vue": ["@unhead/vue@2.1.16", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.16" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-t6QykImeMJcrUTbFB0Coy0cB/OZItHdQFRFLoH8GAVXKWmQORGPrwvueP9me7adOCuMH2uVvrv0nCkbi6zksaA=="],
|
"@unhead/vue": ["@unhead/vue@2.1.16", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.16" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-t6QykImeMJcrUTbFB0Coy0cB/OZItHdQFRFLoH8GAVXKWmQORGPrwvueP9me7adOCuMH2uVvrv0nCkbi6zksaA=="],
|
||||||
|
|
@ -543,6 +572,8 @@
|
||||||
|
|
||||||
"@vitejs/plugin-vue-jsx": ["@vitejs/plugin-vue-jsx@5.1.6", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-syntax-typescript": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7", "@rolldown/pluginutils": "^1.0.1", "@vue/babel-plugin-jsx": "^2.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.0.0" } }, "sha512-YXvi4as2clxt6DFw5+a0tTA97ntiQXm/raR8ofNj3aNwwdlVGTiG2gp7EvfZW17P50acL/9bP0ccF4XnqNmlgA=="],
|
"@vitejs/plugin-vue-jsx": ["@vitejs/plugin-vue-jsx@5.1.6", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-syntax-typescript": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7", "@rolldown/pluginutils": "^1.0.1", "@vue/babel-plugin-jsx": "^2.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.0.0" } }, "sha512-YXvi4as2clxt6DFw5+a0tTA97ntiQXm/raR8ofNj3aNwwdlVGTiG2gp7EvfZW17P50acL/9bP0ccF4XnqNmlgA=="],
|
||||||
|
|
||||||
|
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||||
|
|
||||||
"@vue-macros/common": ["@vue-macros/common@3.1.4", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
"@vue-macros/common": ["@vue-macros/common@3.1.4", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
||||||
|
|
||||||
"@vue/babel-helper-vue-transform-on": ["@vue/babel-helper-vue-transform-on@2.0.1", "", {}, "sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA=="],
|
"@vue/babel-helper-vue-transform-on": ["@vue/babel-helper-vue-transform-on@2.0.1", "", {}, "sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA=="],
|
||||||
|
|
@ -653,6 +684,8 @@
|
||||||
|
|
||||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||||
|
|
||||||
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
@ -755,6 +788,10 @@
|
||||||
|
|
||||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
|
"discord-api-types": ["discord-api-types@0.38.52", "", {}, "sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg=="],
|
||||||
|
|
||||||
|
"discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
@ -771,6 +808,8 @@
|
||||||
|
|
||||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
|
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
||||||
|
|
@ -831,6 +870,8 @@
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="],
|
"exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
|
|
@ -981,6 +1022,12 @@
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||||
|
|
||||||
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
||||||
|
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||||
|
|
||||||
"keycloak-js": ["keycloak-js@26.2.4", "", {}, "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw=="],
|
"keycloak-js": ["keycloak-js@26.2.4", "", {}, "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
@ -1029,8 +1076,26 @@
|
||||||
|
|
||||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||||
|
|
||||||
|
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
|
|
||||||
|
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||||
|
|
||||||
|
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||||
|
|
||||||
|
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||||
|
|
||||||
|
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||||
|
|
||||||
|
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||||
|
|
||||||
|
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||||
|
|
||||||
|
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
|
"magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="],
|
||||||
|
|
||||||
"magic-regexp": ["magic-regexp@0.10.0", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.12", "mlly": "^1.7.2", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "ufo": "^1.5.4", "unplugin": "^2.0.0" } }, "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg=="],
|
"magic-regexp": ["magic-regexp@0.10.0", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.12", "mlly": "^1.7.2", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "ufo": "^1.5.4", "unplugin": "^2.0.0" } }, "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
@ -1441,6 +1506,8 @@
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||||
|
|
||||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
@ -1459,7 +1526,9 @@
|
||||||
|
|
||||||
"unctx": ["unctx@3.0.0", "", { "peerDependencies": { "magic-string": ">=0.30.21", "oxc-parser": ">=0.140.0", "rolldown": "^1.1.5", "unplugin": "^3.3.0" }, "optionalPeers": ["magic-string", "oxc-parser", "rolldown", "unplugin"] }, "sha512-DoXdZVeyi2jyEsn86i8MO5RTItm1kffUkH9/+DQORn3Q688AMOy2551CIl6AdGL2UpwD675wtbNOl75wIQN/uA=="],
|
"unctx": ["unctx@3.0.0", "", { "peerDependencies": { "magic-string": ">=0.30.21", "oxc-parser": ">=0.140.0", "rolldown": "^1.1.5", "unplugin": "^3.3.0" }, "optionalPeers": ["magic-string", "oxc-parser", "rolldown", "unplugin"] }, "sha512-DoXdZVeyi2jyEsn86i8MO5RTItm1kffUkH9/+DQORn3Q688AMOy2551CIl6AdGL2UpwD675wtbNOl75wIQN/uA=="],
|
||||||
|
|
||||||
"undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="],
|
"undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||||
|
|
||||||
|
|
@ -1613,6 +1682,10 @@
|
||||||
|
|
||||||
"@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
"@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
|
|
||||||
|
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
|
|
||||||
"@dxup/nuxt/magic-string": ["magic-string@1.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="],
|
"@dxup/nuxt/magic-string": ["magic-string@1.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
@ -1713,6 +1786,8 @@
|
||||||
|
|
||||||
"nuxt/magic-string": ["magic-string@1.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="],
|
"nuxt/magic-string": ["magic-string@1.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="],
|
||||||
|
|
||||||
|
"nuxt/undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="],
|
||||||
|
|
||||||
"oxc-walker/magic-regexp": ["magic-regexp@0.11.0", "", { "dependencies": { "magic-string": "^0.30.21", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "unplugin": "^3.0.0" } }, "sha512-LG77Z/gVnwz7oaDpD4heX6ryl+lcr4l1B2gnP4MMvt2pGhGC1Dfj7dl1pXpP4ih+VQFLuAadeKVa+lARAzfW+Q=="],
|
"oxc-walker/magic-regexp": ["magic-regexp@0.11.0", "", { "dependencies": { "magic-string": "^0.30.21", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "unplugin": "^3.0.0" } }, "sha512-LG77Z/gVnwz7oaDpD4heX6ryl+lcr4l1B2gnP4MMvt2pGhGC1Dfj7dl1pXpP4ih+VQFLuAadeKVa+lARAzfW+Q=="],
|
||||||
|
|
||||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
|
@ -20,6 +19,7 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
||||||
public: {
|
public: {
|
||||||
keycloakUrl: process.env.NUXT_PUBLIC_KEYCLOAK_URL,
|
keycloakUrl: process.env.NUXT_PUBLIC_KEYCLOAK_URL,
|
||||||
keycloakRealm: process.env.NUXT_PUBLIC_KEYCLOAK_REALM,
|
keycloakRealm: process.env.NUXT_PUBLIC_KEYCLOAK_REALM,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^4.10.0",
|
"@nuxt/ui": "^4.10.0",
|
||||||
|
"discord.js": "^14.27.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"keycloak-js": "^26.2.4",
|
"keycloak-js": "^26.2.4",
|
||||||
"nuxt": "^4.5.0",
|
"nuxt": "^4.5.0",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/simple-icons": "^1.2.92"
|
"@iconify-json/simple-icons": "^1.2.92",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ body {
|
||||||
|
|
||||||
.app-viewport {
|
.app-viewport {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* larger vertical and horizontal gutters to restore visible page gaps */
|
padding: 2rem 1rem;
|
||||||
padding: 3.5rem 2.5rem;
|
|
||||||
display: block !important;
|
display: block !important;
|
||||||
min-height: 100vh !important;
|
min-height: 100vh !important;
|
||||||
}
|
}
|
||||||
|
|
@ -49,20 +48,12 @@ body {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 1280px !important;
|
max-width: 1280px !important;
|
||||||
margin: 0 auto !important;
|
margin: 0 auto !important;
|
||||||
/* inner gutter so content cards don't touch viewport edges */
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-container {
|
.footer-container {
|
||||||
margin-top: 2rem !important;
|
margin-top: 2rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure footer wrapper spacing persists across SSR and reloads */
|
|
||||||
.footer-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 8rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
border-top-color: #00aeef;
|
border-top-color: #00aeef;
|
||||||
|
|
@ -350,4 +341,174 @@ button:disabled, .btn-login-action:disabled {
|
||||||
grid-template-columns: 1fr 300px;
|
grid-template-columns: 1fr 300px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.grid-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.grid-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-image {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logins-card {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logins-header {
|
||||||
|
background: #161b22;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b949e;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link {
|
||||||
|
color: #58a6ff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logins-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item:hover {
|
||||||
|
background: #161b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-xs {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-time {
|
||||||
|
font-size: 0.725rem;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8b949e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changes-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-chip {
|
||||||
|
background: #21262d;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
color: #58a6ff;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ban {
|
||||||
|
background: #490202;
|
||||||
|
border: 1px solid #8b0000;
|
||||||
|
color: #ff7b72;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #161b22;
|
||||||
|
border-top: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
color: #c9d1d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.secondary:hover {
|
||||||
|
background: #21262d;
|
||||||
|
}
|
||||||
|
|
|
||||||
1
public/policy
Submodule
1
public/policy
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 4a501cf3f23a0b57bc370cc124c1c55e80bba5f7
|
||||||
104
server/api/admin/audit-logs.get.ts
Normal file
104
server/api/admin/audit-logs.get.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { query as queryAuthDb } from '../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const queryParams = getQuery(event)
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(String(queryParams.page || '1'), 10))
|
||||||
|
const limit = Math.max(1, Math.min(100, parseInt(String(queryParams.limit || '20'), 10)))
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
const searchTerm = String(queryParams.q || queryParams.search || '').trim()
|
||||||
|
const logType = String(queryParams.type || 'all').trim()
|
||||||
|
const dateFrom = String(queryParams.from || '').trim()
|
||||||
|
const dateTo = String(queryParams.to || '').trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const whereConditions: string[] = []
|
||||||
|
const sqlParams: any[] = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
whereConditions.push(`(
|
||||||
|
CAST(log_id AS TEXT) ILIKE $${paramIndex}
|
||||||
|
OR editor_sfid ILIKE $${paramIndex}
|
||||||
|
OR target_name ILIKE $${paramIndex}
|
||||||
|
OR CAST(target_pid AS TEXT) ILIKE $${paramIndex}
|
||||||
|
OR CAST(editor_pid AS TEXT) ILIKE $${paramIndex}
|
||||||
|
OR changes::text ILIKE $${paramIndex}
|
||||||
|
)`)
|
||||||
|
sqlParams.push(`%${searchTerm}%`)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logType === 'ban') {
|
||||||
|
whereConditions.push(`(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(changes::jsonb->'changes') = 'array'
|
||||||
|
THEN changes::jsonb->'changes'
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
) elem
|
||||||
|
WHERE elem->>'field' = 'account_level'
|
||||||
|
AND (elem->>'newVal')::numeric < 0
|
||||||
|
)
|
||||||
|
OR changes::text ILIKE '%"account_level"%' AND changes::text ILIKE '%"newVal":-%'
|
||||||
|
)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereConditions.push(`created_at >= $${paramIndex}`)
|
||||||
|
sqlParams.push(dateFrom)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo) {
|
||||||
|
whereConditions.push(`created_at <= $${paramIndex}`)
|
||||||
|
sqlParams.push(`${dateTo} 23:59:59`)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(' AND ')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const countResult = await queryAuthDb(
|
||||||
|
`SELECT COUNT(*) AS total FROM audit_logs ${whereClause}`,
|
||||||
|
sqlParams
|
||||||
|
)
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10)
|
||||||
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
const dataSqlParams = [...sqlParams, limit, offset]
|
||||||
|
const limitIndex = paramIndex
|
||||||
|
const offsetIndex = paramIndex + 1
|
||||||
|
|
||||||
|
const dataResult = await queryAuthDb(
|
||||||
|
`SELECT * FROM audit_logs
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${limitIndex} OFFSET $${offsetIndex}`,
|
||||||
|
dataSqlParams
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs: dataResult.rows,
|
||||||
|
total,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[AUDIT LOGS DB QUERY ERROR]:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Audit logs database query failed: ${error.message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
43
server/api/admin/audit-logs/[logid].get.ts
Normal file
43
server/api/admin/audit-logs/[logid].get.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { query as queryAuthDb } from '../../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const logId = getRouterParam(event, 'logid')
|
||||||
|
|
||||||
|
if (!logId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Log ID parameter is required.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = await queryAuthDb(
|
||||||
|
`SELECT * FROM audit_logs WHERE log_id = $1 LIMIT 1`,
|
||||||
|
[logId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
result = await queryAuthDb(
|
||||||
|
`SELECT * FROM ban_logs WHERE log_id = $1 LIMIT 1`,
|
||||||
|
[logId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: `Audit log with ID #${logId} was not found.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0]
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 404) throw err
|
||||||
|
|
||||||
|
console.error('Failed to fetch single audit log:', err.message)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Database error: ${err.message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
109
server/api/admin/rules.get.ts
Normal file
109
server/api/admin/rules.get.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const categories: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'static' | 'general' | 'game'
|
||||||
|
rules?: string[]
|
||||||
|
games?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
iconUrl: string
|
||||||
|
bannerUrl: string
|
||||||
|
rules: string[]
|
||||||
|
}>
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'coc',
|
||||||
|
label: 'Code of Conduct violation',
|
||||||
|
type: 'static'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nsa',
|
||||||
|
label: 'Network Service Agreement violation',
|
||||||
|
type: 'static'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
label: 'General Game Rules Violation',
|
||||||
|
type: 'general',
|
||||||
|
rules: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'game_specific',
|
||||||
|
label: 'Game-Specific violation',
|
||||||
|
type: 'game',
|
||||||
|
games: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const parseMarkdownRules = (content: string): string[] => {
|
||||||
|
const rules: string[] = []
|
||||||
|
const regex = /^###\s+\d+\.\s+\*\*(.*?)\*\*/gm
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
if (match[1]) {
|
||||||
|
rules.push(match[1].trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generalRulesPath = path.resolve(process.cwd(), 'public/policy/GENERAL-NETWORK-RULES.md')
|
||||||
|
if (fs.existsSync(generalRulesPath)) {
|
||||||
|
const content = fs.readFileSync(generalRulesPath, 'utf-8')
|
||||||
|
const generalCategory = categories.find(c => c.id === 'general')
|
||||||
|
if (generalCategory) {
|
||||||
|
generalCategory.rules = parseMarkdownRules(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading GENERAL-NETWORK-RULES.md:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gameRulesDir = path.resolve(process.cwd(), 'public/policy/game-rules')
|
||||||
|
if (fs.existsSync(gameRulesDir)) {
|
||||||
|
const entries = fs.readdirSync(gameRulesDir, { withFileTypes: true })
|
||||||
|
const gameCategory = categories.find(c => c.id === 'game_specific')
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const gameFolderPath = path.join(gameRulesDir, entry.name)
|
||||||
|
const metadataPath = path.join(gameFolderPath, 'metadata.json')
|
||||||
|
const rulePath = path.join(gameFolderPath, 'rule.md')
|
||||||
|
|
||||||
|
if (fs.existsSync(metadataPath) && fs.existsSync(rulePath)) {
|
||||||
|
try {
|
||||||
|
const metadataRaw = fs.readFileSync(metadataPath, 'utf-8')
|
||||||
|
const metadata = JSON.parse(metadataRaw)
|
||||||
|
const ruleContent = fs.readFileSync(rulePath, 'utf-8')
|
||||||
|
const gameRules = parseMarkdownRules(ruleContent)
|
||||||
|
|
||||||
|
const icon = metadata.icons?.icon ? `/policy/game-rules/${entry.name}/${metadata.icons.icon}` : ''
|
||||||
|
const banner = metadata.icons?.banner ? `/policy/game-rules/${entry.name}/${metadata.icons.banner}` : ''
|
||||||
|
|
||||||
|
gameCategory?.games?.push({
|
||||||
|
id: entry.name,
|
||||||
|
name: metadata.name || entry.name,
|
||||||
|
iconUrl: icon,
|
||||||
|
bannerUrl: banner,
|
||||||
|
rules: gameRules
|
||||||
|
})
|
||||||
|
} catch (jsonErr) {
|
||||||
|
console.error(`Error parsing game rule assets for ${entry.name}:`, jsonErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error scanning game-rules folder:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
})
|
||||||
36
server/api/admin/users/[pid].get.ts
Normal file
36
server/api/admin/users/[pid].get.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { queryNnas } from '../../../utils/nnasDb'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const pid = getRouterParam(event, 'pid')
|
||||||
|
|
||||||
|
if (!pid) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'PID parameter is required.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await queryNnas(
|
||||||
|
`SELECT
|
||||||
|
pid, username, email, email_verified_since, birthdate,
|
||||||
|
timezone, country, language, gender, marketing_allowed,
|
||||||
|
off_device_allowed, account_level, creation_date, updated, verification_code
|
||||||
|
FROM users
|
||||||
|
WHERE pid = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[pid]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `User with PID ${pid} not found.` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0]
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || `Database fetch error: ${error.message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
385
server/api/admin/users/[pid].put.ts
Normal file
385
server/api/admin/users/[pid].put.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { queryNnas } from '../../../utils/nnasDb'
|
||||||
|
import { query as queryAuthDb } from '../../../utils/db'
|
||||||
|
import { WebhookClient, EmbedBuilder } from 'discord.js'
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
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 FIELD_ROLES: Record<string, string> = {
|
||||||
|
email: 'can-edit-email',
|
||||||
|
country: 'can-edit-country',
|
||||||
|
language: 'can-edit-language',
|
||||||
|
timezone: 'can-edit-timezone',
|
||||||
|
gender: 'can-edit-gender',
|
||||||
|
account_level: 'can-edit-account_level',
|
||||||
|
marketing_allowed: 'can-edit-marketing_allowed',
|
||||||
|
off_device_allowed: 'can-edit-off_device_allowed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_LEVEL_NAMES: Record<number, string> = {
|
||||||
|
'-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 countryNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
|
const languageNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||||
|
|
||||||
|
function decodeJwtPayload(token: string) {
|
||||||
|
try {
|
||||||
|
const base64Url = token.split('.')[1]
|
||||||
|
if (!base64Url) return null
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8')
|
||||||
|
return JSON.parse(jsonPayload)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(field: string, val: any): string {
|
||||||
|
if (val === null || val === undefined || val === '') return 'N/A'
|
||||||
|
|
||||||
|
if (field === 'account_level') {
|
||||||
|
return ACCOUNT_LEVEL_NAMES[Number(val)] || `Level ${val}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'country') {
|
||||||
|
try {
|
||||||
|
return countryNames.of(String(val).toUpperCase()) || String(val)
|
||||||
|
} catch {
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'language') {
|
||||||
|
try {
|
||||||
|
return languageNames.of(String(val).toLowerCase()) || String(val)
|
||||||
|
} catch {
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val === 'boolean') {
|
||||||
|
return val ? 'True' : 'False'
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDiscordAuditLog({
|
||||||
|
webhookUrl,
|
||||||
|
editorSfid,
|
||||||
|
editorPid,
|
||||||
|
targetPid,
|
||||||
|
targetName,
|
||||||
|
auditLogId,
|
||||||
|
panelBaseUrl,
|
||||||
|
changes,
|
||||||
|
changeReason,
|
||||||
|
modNote
|
||||||
|
}: {
|
||||||
|
webhookUrl: string
|
||||||
|
editorSfid: string
|
||||||
|
editorPid: string
|
||||||
|
targetPid: string
|
||||||
|
targetName: string
|
||||||
|
auditLogId: string
|
||||||
|
panelBaseUrl: string
|
||||||
|
changes: Array<{ field: string; oldVal: any; newVal: any }>
|
||||||
|
changeReason?: string
|
||||||
|
modNote?: string
|
||||||
|
}) {
|
||||||
|
if (!webhookUrl || changes.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webhookClient = new WebhookClient({ url: webhookUrl })
|
||||||
|
|
||||||
|
const editorMiiUrl = editorPid
|
||||||
|
? `https://mii.spfn.net/${editorPid}/main.png`
|
||||||
|
: 'https://git.spbr.net/spacebar/mod-portal-v2/raw/branch/main/public/img_unknown_MiiIcon.png'
|
||||||
|
|
||||||
|
const targetMiiUrl = targetPid
|
||||||
|
? `https://mii.spfn.net/${targetPid}/main.png`
|
||||||
|
: 'https://git.spbr.net/spacebar/mod-portal-v2/raw/branch/main/public/img_unknown_MiiIcon.png'
|
||||||
|
|
||||||
|
const fields = changes.map((c, index) => {
|
||||||
|
const paddedIndex = String(index + 1).padStart(2, '0')
|
||||||
|
const label = FIELD_LABELS[c.field] || c.field
|
||||||
|
const formattedOld = formatValue(c.field, c.oldVal)
|
||||||
|
const formattedNew = formatValue(c.field, c.newVal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `\`${paddedIndex}\` - Updated ${label}`,
|
||||||
|
value: `> Was: \`${formattedOld}\`\nNow: \`${formattedNew}\``,
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (changeReason) {
|
||||||
|
fields.push({
|
||||||
|
name: 'Reason for Change',
|
||||||
|
value: `> ${changeReason}`,
|
||||||
|
inline: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modNote) {
|
||||||
|
fields.push({
|
||||||
|
name: 'Mod Note',
|
||||||
|
value: `> ${modNote}`,
|
||||||
|
inline: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let safeOrigin = panelBaseUrl
|
||||||
|
if (!safeOrigin || safeOrigin === 'http://' || safeOrigin === 'https://') {
|
||||||
|
safeOrigin = 'https://admin.spfn.net'
|
||||||
|
} else if (!safeOrigin.startsWith('http://') && !safeOrigin.startsWith('https://')) {
|
||||||
|
safeOrigin = `http://${safeOrigin}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const logUrl = new URL(`/audit/log/${auditLogId}/`, safeOrigin).href
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setAuthor({
|
||||||
|
name: editorSfid,
|
||||||
|
iconURL: editorMiiUrl
|
||||||
|
})
|
||||||
|
.setTitle(`${editorSfid} updated ${targetName}`)
|
||||||
|
.setThumbnail(targetMiiUrl)
|
||||||
|
.setDescription(`[:link: View log on panel](${logUrl})`)
|
||||||
|
.addFields(fields)
|
||||||
|
.setFooter({
|
||||||
|
text: 'Spacebar Network',
|
||||||
|
iconURL: 'https://git.spbr.net/spacebar/website/raw/branch/master/public/spbr-coloured.png'
|
||||||
|
})
|
||||||
|
.setTimestamp()
|
||||||
|
|
||||||
|
await webhookClient.send({
|
||||||
|
embeds: [embed]
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to dispatch Discord Audit Log via discord.js:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const discordWebhookUrl = config.discordWebhookUrl || process.env.DISCORD_WEBHOOK_URL
|
||||||
|
const panelBaseUrl = config.public?.siteUrl || process.env.SITE_URL
|
||||||
|
|
||||||
|
const pid = getRouterParam(event, 'pid')
|
||||||
|
|
||||||
|
if (!pid) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'PID parameter is required.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = getHeader(event, 'authorization')
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Unauthorized: Missing or invalid Authorization token.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1]
|
||||||
|
const decoded = decodeJwtPayload(token)
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Unauthorized: Token payload invalid.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let editorSfid = decoded?.preferred_username || decoded?.name || 'Unknown Admin'
|
||||||
|
let editorPid = decoded?.pid || null
|
||||||
|
|
||||||
|
const keycloakId = decoded?.sub
|
||||||
|
const editorEmail = decoded?.email
|
||||||
|
|
||||||
|
try {
|
||||||
|
const editorRes = await queryAuthDb(
|
||||||
|
`SELECT sfid, pid FROM users WHERE keycloak_id = $1 OR email = $2 LIMIT 1`,
|
||||||
|
[keycloakId, editorEmail]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (editorRes.rows.length > 0) {
|
||||||
|
editorSfid = editorRes.rows[0].sfid || editorSfid
|
||||||
|
editorPid = editorRes.rows[0].pid || editorPid
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('Could not fetch editor author details from auth DB:', err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const realmRoles = decoded?.realm_access?.roles || []
|
||||||
|
const clientRoles = Object.values(decoded?.resource_access || {})
|
||||||
|
.flatMap((client: any) => client.roles || [])
|
||||||
|
const userRoles = [...realmRoles, ...clientRoles].map(r => String(r).toLowerCase())
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
let existingUser: any = null
|
||||||
|
try {
|
||||||
|
const existingRes = await queryNnas(`SELECT * FROM users WHERE pid = $1 LIMIT 1`, [pid])
|
||||||
|
if (existingRes.rows.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found.' })
|
||||||
|
}
|
||||||
|
existingUser = existingRes.rows[0]
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 404) throw err
|
||||||
|
console.warn('Could not fetch target user record from NNAS DB:', err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
const auditChanges: Array<{ field: string; oldVal: any; newVal: any }> = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
const changeReason = body.change_reason ? String(body.change_reason).trim() : ''
|
||||||
|
const modNote = body.mod_note ? String(body.mod_note).trim() : ''
|
||||||
|
let newlyAppliedBanLevel: number | null = null
|
||||||
|
|
||||||
|
for (const [field, requiredRole] of Object.entries(FIELD_ROLES)) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
const hasPermission = userRoles.includes(requiredRole.toLowerCase())
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: `Forbidden: You do not have permission to edit the '${field}' field.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldVal = existingUser ? existingUser[field] : null
|
||||||
|
const newVal = body[field]
|
||||||
|
|
||||||
|
if (String(oldVal) !== String(newVal)) {
|
||||||
|
const isTransitionToBanned = Number(oldVal) >= 0 && Number(newVal) < 0
|
||||||
|
|
||||||
|
if (field === 'account_level' && isTransitionToBanned) {
|
||||||
|
if (!changeReason) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'A violation reason is required when banning an account.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
newlyAppliedBanLevel = Number(newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`${field} = $${paramIndex}`)
|
||||||
|
values.push(newVal)
|
||||||
|
paramIndex++
|
||||||
|
|
||||||
|
auditChanges.push({ field, oldVal, newVal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return existingUser || { message: 'No fields were modified.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated = NOW()`)
|
||||||
|
values.push(pid)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryStr = `
|
||||||
|
UPDATE users
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE pid = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await queryNnas(queryStr, values)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found or update failed.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = result.rows[0]
|
||||||
|
const targetName = updatedUser.username || updatedUser.sfid || `User ${pid}`
|
||||||
|
const auditLogId = String(Date.now())
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryAuthDb(
|
||||||
|
`INSERT INTO audit_logs (log_id, editor_sfid, editor_pid, target_name, target_pid, changes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
auditLogId,
|
||||||
|
editorSfid,
|
||||||
|
editorPid,
|
||||||
|
targetName,
|
||||||
|
Number(pid),
|
||||||
|
JSON.stringify({
|
||||||
|
changes: auditChanges,
|
||||||
|
reason: changeReason || null,
|
||||||
|
mod_note: modNote || null
|
||||||
|
})
|
||||||
|
]
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to save audit log to DB:', err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newlyAppliedBanLevel !== null) {
|
||||||
|
try {
|
||||||
|
await queryAuthDb(
|
||||||
|
`INSERT INTO ban_logs (log_id, target_pid, target_name, editor_sfid, editor_pid, ban_level, reason, mod_note)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
auditLogId,
|
||||||
|
Number(pid),
|
||||||
|
targetName,
|
||||||
|
editorSfid,
|
||||||
|
editorPid,
|
||||||
|
newlyAppliedBanLevel,
|
||||||
|
changeReason,
|
||||||
|
modNote || null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to save ban log to DB:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordWebhookUrl) {
|
||||||
|
sendDiscordAuditLog({
|
||||||
|
webhookUrl: discordWebhookUrl,
|
||||||
|
editorSfid,
|
||||||
|
editorPid,
|
||||||
|
targetPid: pid,
|
||||||
|
targetName,
|
||||||
|
auditLogId,
|
||||||
|
panelBaseUrl,
|
||||||
|
changes: auditChanges,
|
||||||
|
changeReason,
|
||||||
|
modNote
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedUser
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || `Database update error: ${error.message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -28,7 +28,35 @@ const initDb = async () => {
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS pid INTEGER;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS pid INTEGER;
|
||||||
`)
|
`)
|
||||||
|
|
||||||
console.log('[DB INIT]: Table "users" checked/updated successfully.')
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
log_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
editor_sfid VARCHAR(255),
|
||||||
|
editor_pid INTEGER,
|
||||||
|
target_name VARCHAR(255),
|
||||||
|
target_pid INTEGER,
|
||||||
|
changes JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ban_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
log_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
target_pid INTEGER NOT NULL,
|
||||||
|
target_name VARCHAR(255),
|
||||||
|
editor_sfid VARCHAR(255),
|
||||||
|
editor_pid INTEGER,
|
||||||
|
ban_level INTEGER NOT NULL,
|
||||||
|
reason VARCHAR(255) NOT NULL,
|
||||||
|
mod_note TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log('[DB INIT]: Tables "users", "audit_logs", and "ban_logs" checked/updated successfully.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[DB INIT ERROR]:', err)
|
console.error('[DB INIT ERROR]:', err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue