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

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>
</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,8 +158,16 @@ onMounted(async () => {
</div>
</div>
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
{{ user.email }}
<div style="display: flex; align-items: center; gap: 1rem;">
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
{{ user.email }}
</div>
<button
type="button"
style="padding: 0.35rem 0.75rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.8rem; font-weight: 600; pointer-events: none;"
>
View
</button>
</div>
</div>
</div>
@ -193,4 +207,11 @@ onMounted(async () => {
</main>
</div>
</div>
</template>
</template>
<style scoped>
.user-card:hover {
background: #161b22 !important;
border-color: #58a6ff !important;
}
</style>

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