bunch of shit

This commit is contained in:
kittentm 2026-08-05 06:36:58 +02:00
commit 0f577eef4d
Signed by: kitten
GPG key ID: 394B4EABE4405A83
22 changed files with 2889 additions and 37 deletions

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "public/policy"]
path = public/policy
url = https://git.spbr.net/spacebar/policy.git
branch = main

View file

@ -41,7 +41,7 @@ onMounted(async () => {
justify-content: center;
align-items: center;
min-height: 100vh;
padding-top: 5rem; /* Added padding-top */
padding-top: 5rem;
}
.app-viewport,
@ -64,6 +64,6 @@ onMounted(async () => {
.footer-wrapper {
width: 100%;
margin-top: 8rem; /* Added margin-top */
margin-top: 8rem;
}
</style>

View file

@ -42,6 +42,13 @@ const emit = defineEmits(['logout', 'navigate'])
@click="emit('navigate', '/search')">
<span>Search Users</span>
</button>
<button type="button" class="nav-item"
:class="{ 'active-nav': activeTab === 'audit' || activeTab === 'audit/log' }"
@click="emit('navigate', '/audit/log')">
<span>Audit Logs</span>
</button>
<button type="button" class="nav-item logout-btn" @click="emit('logout')">
<span>Sign Out</span>
</button>

View file

@ -1,13 +1,39 @@
import { ref } from 'vue'
import { ref, computed } from 'vue'
const userToken = ref(null)
const refreshToken = ref(null)
const userProfile = ref(null)
const userRoles = ref([])
const isAuthenticated = ref(false)
const isInitialized = ref(false)
let sessionPollTimer = null
const extractRolesFromToken = (token) => {
if (!token) return []
try {
const payloadBase64 = token.split('.')[1]
const base64 = payloadBase64.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
const payload = JSON.parse(jsonPayload)
const realmRoles = payload?.realm_access?.roles || []
const clientRoles = Object.values(payload?.resource_access || {})
.flatMap(client => client.roles || [])
const directRoles = payload?.roles || []
return [...realmRoles, ...clientRoles, ...directRoles].map(r => String(r).toLowerCase())
} catch (e) {
console.warn('Failed to parse roles from token:', e)
return []
}
}
const getStoredRefreshToken = () => {
if (refreshToken.value) {
return refreshToken.value
@ -37,6 +63,8 @@ export const useAuth = () => {
const setSession = (data) => {
userToken.value = data.access_token
userRoles.value = extractRolesFromToken(data.access_token)
if (data.refresh_token) {
refreshToken.value = data.refresh_token
if (import.meta.client) {
@ -51,6 +79,7 @@ export const useAuth = () => {
userToken.value = null
refreshToken.value = null
userProfile.value = null
userRoles.value = []
isAuthenticated.value = false
if (import.meta.client) {
localStorage.removeItem('keycloak_refresh_token')
@ -69,6 +98,7 @@ export const useAuth = () => {
setSession(data)
window.history.replaceState({}, document.title, window.location.pathname)
await fetchUserProfile()
startLiveSessionPolling()
@ -123,9 +153,11 @@ export const useAuth = () => {
if (res.active) {
isAuthenticated.value = true
if (!userProfile.value) {
await fetchUserProfile()
}
return true
} else {
return await silentRefresh()
@ -144,6 +176,12 @@ export const useAuth = () => {
await navigateTo(redirectTo, { replace })
return false
}
if (result.isFirstLogin) {
await navigateTo('/welcome', { replace: true })
return true
}
return true
}
@ -207,7 +245,9 @@ export const useAuth = () => {
return {
userToken,
token: userToken,
userProfile,
userRoles,
isAuthenticated,
isInitialized,
exchangeCodeForToken,

View file

@ -13,7 +13,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
await initializeAuth({ redirectTo: '/oauth', replace: true })
}
//whats the shape of Italy?
if (!isAuthenticated.value) {
return navigateTo('/oauth', { replace: true })
}

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

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

View file

@ -12,9 +12,55 @@ const {
const route = useRoute()
const recentLogins = ref([
{ user: 'admin_user', time: '2 mins ago', ip: '192.168.1.10' }
])
const recentActions = ref([])
const isLoadingActions = ref(true)
const FIELD_LABELS = {
account_level: 'Access Level',
country: 'Country',
language: 'Language',
timezone: 'Timezone',
gender: 'Gender',
email: 'Email',
marketing_allowed: 'Marketing Allowed',
off_device_allowed: 'Off-Device Allowed'
}
const formatDate = (isoStr) => {
if (!isoStr) return 'N/A'
try {
return new Date(isoStr).toLocaleString('en-US', {
dateStyle: 'short',
timeStyle: 'short'
})
} catch {
return isoStr
}
}
const parseLogChanges = (rawChanges) => {
if (!rawChanges) return { changes: [] }
if (typeof rawChanges === 'string') {
try {
return JSON.parse(rawChanges)
} catch {
return { changes: [] }
}
}
return rawChanges
}
const fetchRecentActions = async () => {
isLoadingActions.value = true
try {
const response = await $fetch('/api/admin/audit-logs?limit=3&page=1')
recentActions.value = response.logs || []
} catch (err) {
console.error('Failed to fetch recent actions:', err)
} finally {
isLoadingActions.value = false
}
}
const handleLogout = () => {
logout()
@ -26,6 +72,9 @@ const handleNavigate = (path) => {
onMounted(async () => {
await initializeAuth({ code: route.query.code, redirectTo: '/oauth' })
if (isAuthenticated.value) {
await fetchRecentActions()
}
})
</script>
@ -68,23 +117,76 @@ onMounted(async () => {
<div class="logins-right-col">
<div class="logins-card">
<div class="logins-header archivo-black">
RECENT LOGINS
<div class="logins-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
<span>RECENT ACTIONS</span>
<NuxtLink to="/audit/log" class="view-all-link">View All </NuxtLink>
</div>
<ul class="logins-list">
<li v-for="(log, idx) in recentLogins" :key="idx" class="login-item">
<div class="login-user">{{ log.user }}</div>
<div class="login-details">
<span>{{ log.ip }}</span> &bull;
<span class="login-time">{{ log.time }}</span>
<div v-if="isLoadingActions" class="loading-state">
<div class="spinner sm"></div>
<span>Loading recent actions...</span>
</div>
<ul v-else-if="recentActions.length > 0" class="logins-list">
<li
v-for="log in recentActions"
:key="log.log_id || log.id"
class="login-item action-item"
@click="handleNavigate(`/audit/log/${log.log_id || log.id}`)"
>
<div class="action-top">
<div class="user-block">
<img
:src="log.editor_pid ? `https://mii.spfn.net/${log.editor_pid}/main.png` : '/img_unknown_MiiIcon.png'"
class="avatar-xs"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<span class="editor-name">{{ log.editor_sfid || 'System' }}</span>
</div>
<span class="action-time">{{ formatDate(log.created_at) }}</span>
</div>
<div class="action-summary">
<span class="target-name">Modified {{ log.target_name }}</span>
<span v-if="log.ban_level !== undefined && log.ban_level !== null" class="badge-ban">
Banned
</span>
<span v-else class="changes-tags">
<span
v-for="change in parseLogChanges(log.changes).changes.slice(0, 2)"
:key="change.field"
class="change-chip"
>
{{ FIELD_LABELS[change.field] || change.field }}
</span>
</span>
</div>
</li>
</ul>
<div v-else class="empty-state">
<p>No recent actions logged.</p>
</div>
<div class="card-footer">
<button
type="button"
class="btn-action secondary sm"
style="width: 100%;"
@click="handleNavigate('/audit/log')"
>
Go to Audit Log Center
</button>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</template>

View file

@ -16,7 +16,6 @@ const isSearching = ref(false)
const hasSearched = ref(false)
const errorMessage = ref('')
// pagination state
const currentPage = ref(1)
const totalPages = ref(0)
const totalResults = ref(0)
@ -30,6 +29,11 @@ const handleNavigate = (path) => {
navigateTo(path)
}
const navigateToUser = (pid) => {
if (!pid) return
navigateTo(`/users/${pid}`)
}
const executeSearch = async (page = 1) => {
const query = searchQuery.value.trim()
if (!query) return
@ -133,7 +137,9 @@ onMounted(async () => {
<div
v-for="user in searchResults"
:key="user.id"
style="display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px;"
class="user-card"
style="display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease;"
@click="navigateToUser(user.pid)"
>
<div style="display: flex; align-items: center; gap: 0.85rem;">
<img
@ -152,9 +158,17 @@ onMounted(async () => {
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
{{ user.email }}
</div>
<button
type="button"
style="padding: 0.35rem 0.75rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.8rem; font-weight: 600; pointer-events: none;"
>
View
</button>
</div>
</div>
</div>
@ -194,3 +208,10 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.user-card:hover {
background: #161b22 !important;
border-color: #58a6ff !important;
}
</style>

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

View file

@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
const route = useRoute()
const { userToken, isAuthenticated, initializeAuth } = useAuth()
const sfidInput = ref('')
@ -13,7 +14,17 @@ const errorMessage = ref('')
onMounted(async () => {
const isReady = await initializeAuth({ redirectTo: '/oauth', replace: true })
if (isReady && isAuthenticated.value) {
if (!isReady) {
return
}
if (!isAuthenticated.value) {
await navigateTo('/oauth', { replace: true })
return
}
if (route.query.redirect === 'home') {
await navigateTo('/', { replace: true })
}
})

View file

@ -6,6 +6,8 @@
"name": "mod-portal-v2",
"dependencies": {
"@nuxt/ui": "^4.10.0",
"discord.js": "^14.27.0",
"jsonwebtoken": "^9.0.3",
"keycloak-js": "^26.2.4",
"nuxt": "^4.5.0",
"pg": "^8.22.0",
@ -15,6 +17,7 @@
},
"devDependencies": {
"@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=="],
"@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/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=="],
"@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/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/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/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/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=="],
"@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/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-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=="],
"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=="],
"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=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@ -771,6 +808,8 @@
"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=="],
"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=="],
"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-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=="],
"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=="],
"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.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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
@ -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=="],
"@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=="],
"@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/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=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],

View file

@ -1,4 +1,3 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
@ -20,6 +19,7 @@ export default defineNuxtConfig({
},
runtimeConfig: {
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
public: {
keycloakUrl: process.env.NUXT_PUBLIC_KEYCLOAK_URL,
keycloakRealm: process.env.NUXT_PUBLIC_KEYCLOAK_REALM,

View file

@ -11,6 +11,8 @@
},
"dependencies": {
"@nuxt/ui": "^4.10.0",
"discord.js": "^14.27.0",
"jsonwebtoken": "^9.0.3",
"keycloak-js": "^26.2.4",
"nuxt": "^4.5.0",
"pg": "^8.22.0",
@ -19,6 +21,7 @@
"vue-router": "^5.2.0"
},
"devDependencies": {
"@iconify-json/simple-icons": "^1.2.92"
"@iconify-json/simple-icons": "^1.2.92",
"@types/jsonwebtoken": "^9.0.10"
}
}

View file

@ -33,8 +33,7 @@ body {
.app-viewport {
width: 100%;
/* larger vertical and horizontal gutters to restore visible page gaps */
padding: 3.5rem 2.5rem;
padding: 2rem 1rem;
display: block !important;
min-height: 100vh !important;
}
@ -49,20 +48,12 @@ body {
width: 100% !important;
max-width: 1280px !important;
margin: 0 auto !important;
/* inner gutter so content cards don't touch viewport edges */
padding: 0 1.25rem;
}
.footer-container {
margin-top: 2rem !important;
}
/* Ensure footer wrapper spacing persists across SSR and reloads */
.footer-wrapper {
width: 100%;
margin-top: 8rem !important;
}
.spinner {
border: 3px solid rgba(255, 255, 255, 0.15);
border-top-color: #00aeef;
@ -351,3 +342,173 @@ button:disabled, .btn-login-action:disabled {
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

@ -0,0 +1 @@
Subproject commit 4a501cf3f23a0b57bc370cc124c1c55e80bba5f7

View 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}`
})
}
})

View 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}`
})
}
})

View 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
})

View 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}`
})
}
})

View 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}`
})
}
})

View file

@ -28,7 +28,35 @@ const initDb = async () => {
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) {
console.error('[DB INIT ERROR]:', err)
}