Files
2026-07-01 02:27:38 +00:00

777 lines
30 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ $t('users.title') }}</h1>
<p class="text-slate-400 mt-1">{{ $t('users.subtitle') }}</p>
</div>
<!-- Search & Filter Bar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('common.search_placeholder')"
class="w-full pl-10 pr-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition"
/>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Status Filter -->
<select
v-model="statusFilter"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('users.all_statuses') }}</option>
<option value="active">{{ $t('users.status_active') }}</option>
<option value="inactive">{{ $t('users.status_inactive') }}</option>
<option value="deleted">{{ $t('users.status_deleted') }}</option>
<option value="banned">{{ $t('users.status_banned') }}</option>
</select>
<!-- Role Filter -->
<select
v-model="roleFilter"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('users.all_roles') }}</option>
<option value="user">{{ $t('users.role_user') }}</option>
<option value="admin">{{ $t('users.role_admin') }}</option>
<option value="staff">{{ $t('users.role_staff') }}</option>
<option value="superadmin">{{ $t('users.role_superadmin') }}</option>
</select>
<!-- Plan Filter -->
<select
v-model="planFilter"
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="all">{{ $t('users.all_plans') }}</option>
<option value="FREE">{{ $t('users.plan_free') }}</option>
<option value="PREMIUM">{{ $t('users.plan_premium') }}</option>
<option value="ENTERPRISE">{{ $t('users.plan_enterprise') }}</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="flex flex-col items-center gap-3">
<svg class="w-8 h-8 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p class="text-slate-400 text-sm">{{ $t('common.loading') }}</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center">
<svg class="w-12 h-12 text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-red-400 text-sm mb-2">{{ error }}</p>
<button @click="fetchUsers" class="px-4 py-2 text-sm bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30 rounded-lg transition">
{{ $t('common.retry') }}
</button>
</div>
<!-- Content -->
<template v-else>
<!-- Stats Cards Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<!-- Total Users -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.total_users') }}</p>
<p class="text-2xl font-bold text-white">{{ stats.total_users }}</p>
</div>
</div>
</div>
<!-- Active Users -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.active_users') }}</p>
<p class="text-2xl font-bold text-emerald-400">{{ stats.active_users }}</p>
</div>
</div>
</div>
<!-- Deleted Users -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-red-500/10 text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.deleted_users') }}</p>
<p class="text-2xl font-bold text-red-400">{{ stats.deleted_users }}</p>
</div>
</div>
</div>
<!-- Banned Users -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.banned_users') }}</p>
<p class="text-2xl font-bold text-amber-400">{{ stats.banned_users }}</p>
</div>
</div>
</div>
<!-- New Today -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('users.new_today') }}</p>
<p class="text-2xl font-bold text-cyan-400">{{ stats.new_users_today }}</p>
</div>
</div>
</div>
</div>
<!-- Registration Trend Chart -->
<div class="mb-8">
<UserTrendChart :data="stats.registration_trend" :loading="loading" />
</div>
<!-- Filtered results indicator -->
<div v-if="isFilterActive" class="mb-4 text-xs text-slate-500">
{{ $t('users.filtered_results') }}: <span class="text-slate-300 font-semibold">{{ users.length }}</span> / {{ totalCount }} db
</div>
<!-- Bulk Actions Bar -->
<div v-if="selectedUserIds.length > 0 && canEditUsers" class="mb-4 px-4 py-3 bg-indigo-900/30 border border-indigo-700/50 rounded-xl flex items-center justify-between">
<p class="text-sm text-indigo-300">
{{ $t('users.selected_count', { count: selectedUserIds.length }) }}
</p>
<div class="flex items-center gap-2">
<button
@click="bulkActivate"
class="px-3 py-1.5 text-xs font-medium bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 rounded-lg transition"
>
{{ $t('users.bulk_activate') }}
</button>
<button
@click="bulkDeactivate"
class="px-3 py-1.5 text-xs font-medium bg-red-600/20 text-red-300 hover:bg-red-600/30 rounded-lg transition"
>
{{ $t('users.bulk_deactivate') }}
</button>
<button
@click="clearSelection"
class="px-3 py-1.5 text-xs font-medium bg-slate-600/20 text-slate-300 hover:bg-slate-600/30 rounded-lg transition"
>
{{ $t('users.clear_selection') }}
</button>
</div>
</div>
<!-- Data Table -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700 bg-slate-800/80">
<th class="text-left py-3.5 px-4 w-10">
<input
type="checkbox"
:checked="allSelected"
@change="toggleSelectAll"
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
/>
</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">ID</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('common.email') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('common.name') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.role') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('common.status') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.package') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.registration') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('users.language') }}</th>
<th class="text-left py-3.5 px-4 text-xs font-semibold uppercase tracking-wider text-slate-400">{{ $t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-b border-slate-700/50 hover:bg-slate-700/30 transition"
>
<!-- Checkbox -->
<td class="py-3.5 px-4">
<input
type="checkbox"
:checked="selectedUserIds.includes(user.id)"
@change="toggleUserSelection(user.id)"
class="rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500/50"
/>
</td>
<!-- ID -->
<td class="py-3.5 px-4 text-slate-400 font-mono text-xs">{{ user.id }}</td>
<!-- Email -->
<td class="py-3.5 px-4">
<NuxtLink
:to="'/users/' + user.id"
class="text-sm font-medium text-white hover:underline cursor-pointer hover:text-indigo-400 transition-colors"
>
{{ user.email }}
</NuxtLink>
</td>
<!-- Name -->
<td class="py-3.5 px-4">
<span class="text-slate-300">{{ user.person_name || '—' }}</span>
</td>
<!-- Role -->
<td class="py-3.5 px-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="roleBadgeClass(user.role)"
>
{{ roleLabel(user.role) }}
</span>
</td>
<!-- Status -->
<td class="py-3.5 px-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="statusBadgeClass(user)"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="statusDotClass(user)"
></span>
{{ statusLabel(user) }}
</span>
</td>
<!-- Package -->
<td class="py-3.5 px-4">
<span
class="px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="planBadgeClass(user.subscription_plan)"
>
{{ user.subscription_plan || '—' }}
</span>
</td>
<!-- Registration -->
<td class="py-3.5 px-4 text-slate-300 text-xs">
{{ formatDate(user.created_at) }}
</td>
<!-- Language -->
<td class="py-3.5 px-4">
<span class="text-slate-400 text-xs uppercase">{{ user.preferred_language || '—' }}</span>
</td>
<!-- Actions -->
<td class="py-3.5 px-4">
<div class="flex items-center gap-2">
<NuxtLink
:to="'/users/' + user.id"
class="px-3 py-1.5 text-xs font-medium bg-slate-600/20 text-slate-300 hover:bg-slate-600/30 rounded-lg transition"
>
{{ $t('common.view') }}
</NuxtLink>
<button
v-if="!user.is_deleted && canEditUsers"
@click="toggleUserActive(user)"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition"
:class="user.is_active
? 'bg-red-500/10 text-red-300 hover:bg-red-500/20'
: 'bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'"
>
{{ user.is_active ? $t('common.deactivate') : $t('common.activate') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div
v-if="users.length === 0 && !loading"
class="flex flex-col items-center justify-center py-16 text-center"
>
<svg class="w-12 h-12 text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-slate-400 text-sm">{{ $t('common.no_results') }}</p>
</div>
</div>
<!-- Pagination -->
<div v-if="totalCount > limit" class="flex items-center justify-between mt-6">
<p class="text-xs text-slate-500">
{{ $t('users.showing') }} {{ skip + 1 }}{{ Math.min(skip + limit, totalCount) }} / {{ totalCount }}
</p>
<div class="flex items-center gap-2">
<button
@click="prevPage"
:disabled="skip === 0"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="skip > 0 ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-800 text-slate-600'"
>
{{ $t('common.prev') }}
</button>
<button
@click="nextPage"
:disabled="skip + limit >= totalCount"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="skip + limit < totalCount ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-800 text-slate-600'"
>
{{ $t('common.next') }}
</button>
</div>
</div>
</template>
<!-- Notification -->
<div
v-if="notification"
class="fixed bottom-6 right-6 px-5 py-3 rounded-xl shadow-xl text-sm font-medium z-50"
:class="notification.type === 'success'
? 'bg-emerald-600 text-white'
: 'bg-red-600 text-white'"
>
{{ notification.message }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '~/stores/auth'
import UserTrendChart from '~/components/charts/UserTrendChart.vue'
const router = useRouter()
const auth = useAuthStore()
definePageMeta({
middleware: 'auth',
})
// ── RBAC Permission Check ──────────────────────────────────────────
const canViewUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:view']
})
const canEditUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:edit']
})
const canBanUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:ban']
})
const canDeleteUsers = computed(() => {
if (auth.isAdmin) return true
return !!auth.user?.system_capabilities?.['users:delete']
})
// Redirect if no view permission
if (!canViewUsers.value && !auth.isAdmin) {
router.push('/')
}
// ── Types ──────────────────────────────────────────────────────────
interface UserListItem {
id: number
email: string
role: string
is_active: boolean
is_deleted: boolean
is_vip: boolean
subscription_plan: string
preferred_language: string
region_code: string
created_at: string | null
person_name: string | null
person_phone: string | null
}
interface UserListResponse {
total: number
skip: number
limit: number
users: UserListItem[]
}
interface UserStats {
total_users: number
active_users: number
deleted_users: number
banned_users: number
new_users_today: number
new_users_this_week: number
new_users_this_month: number
users_by_role: Record<string, number>
users_by_plan: Record<string, number>
users_by_language: Record<string, number>
users_with_person: number
users_without_person: number
registration_trend: { date: string; count: number }[]
active_organizations_count: number
total_memberships: number
}
// ── State ──────────────────────────────────────────────────────────
const loading = ref(true)
const error = ref<string | null>(null)
const users = ref<UserListItem[]>([])
const totalCount = ref(0)
const skip = ref(0)
const limit = ref(50)
const searchQuery = ref('')
const statusFilter = ref('all')
const roleFilter = ref('all')
const planFilter = ref('all')
const stats = ref<UserStats>({
total_users: 0,
active_users: 0,
deleted_users: 0,
banned_users: 0,
new_users_today: 0,
new_users_this_week: 0,
new_users_this_month: 0,
users_by_role: {},
users_by_plan: {},
users_by_language: {},
users_with_person: 0,
users_without_person: 0,
registration_trend: [],
active_organizations_count: 0,
total_memberships: 0,
})
// Bulk selection
const selectedUserIds = ref<number[]>([])
// Notification
const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Debounced Search Timer ──────────────────────────────────────────
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedFetchUsers() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
skip.value = 0
fetchUsers()
}, 400)
}
// ── Watchers ────────────────────────────────────────────────────────
watch(searchQuery, () => {
debouncedFetchUsers()
})
watch(statusFilter, () => {
skip.value = 0
fetchUsers()
})
watch(roleFilter, () => {
skip.value = 0
fetchUsers()
})
watch(planFilter, () => {
skip.value = 0
fetchUsers()
})
// ── Computed ───────────────────────────────────────────────────────
const isFilterActive = computed(() => {
return searchQuery.value !== '' || statusFilter.value !== 'all' || roleFilter.value !== 'all' || planFilter.value !== 'all'
})
const allSelected = computed(() => {
return users.value.length > 0 && selectedUserIds.value.length === users.value.length
})
// ── Auth Helper ────────────────────────────────────────────────────
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
// ── Data Fetching ──────────────────────────────────────────────────
async function fetchUsers() {
loading.value = true
error.value = null
try {
const params: Record<string, any> = { skip: skip.value, limit: limit.value }
if (searchQuery.value) {
params.search_term = searchQuery.value
}
// Status filter mapping: frontend uses combined status_filter, backend uses is_active/is_deleted
if (statusFilter.value !== 'all') {
switch (statusFilter.value) {
case 'active':
params.is_active = true
break
case 'inactive':
params.is_active = false
break
case 'deleted':
params.is_deleted = true
break
case 'banned':
params.is_active = false
break
}
}
if (roleFilter.value !== 'all') {
params.role = roleFilter.value
}
if (planFilter.value !== 'all') {
params.plan_filter = planFilter.value
}
const response = await $fetch<UserListResponse>('/api/v1/admin/users', {
params,
headers: getAuthHeaders(),
})
users.value = response.users
totalCount.value = response.total
} catch (err: any) {
console.error('[Users] Failed to fetch users:', err)
error.value = err?.data?.detail || err?.message || 'Failed to load users'
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const response = await $fetch<UserStats>('/api/v1/admin/users/stats', {
headers: getAuthHeaders(),
})
stats.value = response
} catch (err: any) {
console.error('[Users] Failed to fetch stats:', err)
}
}
// ── Pagination ─────────────────────────────────────────────────────
function nextPage() {
if (skip.value + limit.value < totalCount.value) {
skip.value += limit.value
fetchUsers()
}
}
function prevPage() {
if (skip.value > 0) {
skip.value = Math.max(0, skip.value - limit.value)
fetchUsers()
}
}
// ── Bulk Selection ─────────────────────────────────────────────────
function toggleSelectAll() {
if (allSelected.value) {
selectedUserIds.value = []
} else {
selectedUserIds.value = users.value.map(u => u.id)
}
}
function toggleUserSelection(userId: number) {
const idx = selectedUserIds.value.indexOf(userId)
if (idx === -1) {
selectedUserIds.value.push(userId)
} else {
selectedUserIds.value.splice(idx, 1)
}
}
function clearSelection() {
selectedUserIds.value = []
}
async function bulkActivate() {
if (selectedUserIds.value.length === 0) return
try {
await $fetch('/api/v1/admin/users/bulk-action', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
user_ids: selectedUserIds.value,
action: 'activate',
},
})
showNotification('success', `${selectedUserIds.value.length} user(s) activated successfully`)
selectedUserIds.value = []
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Bulk activate failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Bulk activate failed')
}
}
async function bulkDeactivate() {
if (selectedUserIds.value.length === 0) return
try {
await $fetch('/api/v1/admin/users/bulk-action', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
user_ids: selectedUserIds.value,
action: 'deactivate',
},
})
showNotification('success', `${selectedUserIds.value.length} user(s) deactivated successfully`)
selectedUserIds.value = []
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Bulk deactivate failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Bulk deactivate failed')
}
}
// ── Single User Toggle ─────────────────────────────────────────────
async function toggleUserActive(user: UserListItem) {
const newActive = !user.is_active
try {
await $fetch(`/api/v1/admin/users/${user.id}`, {
method: 'PATCH',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: { is_active: newActive },
})
showNotification('success', `User "${user.email}" ${newActive ? 'activated' : 'deactivated'} successfully`)
await fetchUsers()
await fetchStats()
} catch (err: any) {
console.error('[Users] Toggle status failed:', err)
showNotification('error', err?.data?.detail || err?.message || 'Failed to toggle user status')
}
}
// ── Helpers ────────────────────────────────────────────────────────
function roleBadgeClass(role: string): string {
const r = role.toLowerCase()
if (r === 'superadmin') return 'bg-purple-500/20 text-purple-300'
if (r === 'admin') return 'bg-indigo-500/20 text-indigo-300'
if (r === 'staff') return 'bg-cyan-500/20 text-cyan-300'
return 'bg-slate-500/20 text-slate-300'
}
function roleLabel(role: string): string {
const r = role.toLowerCase()
if (r === 'superadmin') return 'Superadmin'
if (r === 'admin') return 'Admin'
if (r === 'staff') return 'Staff'
return 'User'
}
function statusBadgeClass(user: UserListItem): string {
if (user.is_deleted) return 'bg-red-500/10 text-red-400'
if (!user.is_active) return 'bg-amber-500/10 text-amber-400'
return 'bg-emerald-500/10 text-emerald-400'
}
function statusDotClass(user: UserListItem): string {
if (user.is_deleted) return 'bg-red-400'
if (!user.is_active) return 'bg-amber-400'
return 'bg-emerald-400'
}
function statusLabel(user: UserListItem): string {
if (user.is_deleted) return 'Deleted'
if (!user.is_active) return 'Inactive'
return 'Active'
}
function planBadgeClass(plan: string): string {
const p = (plan || '').toLowerCase()
if (p.includes('enterprise') || p.includes('vip')) return 'bg-purple-500/20 text-purple-300'
if (p.includes('premium') || p.includes('pro')) return 'bg-amber-500/20 text-amber-300'
return 'bg-slate-500/20 text-slate-300'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return dateStr
}
}
function showNotification(type: 'success' | 'error', message: string) {
notification.value = { type, message }
setTimeout(() => {
notification.value = null
}, 4000)
}
// ── Lifecycle ──────────────────────────────────────────────────────
onMounted(() => {
fetchUsers()
fetchStats()
})
</script>