mod-portal-v2/app/pages/users/[pid]/index.vue

665 lines
24 KiB
Vue
Raw Normal View History

2026-08-05 06:36:58 +02:00
<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>