655 lines
20 KiB
Vue
655 lines
20 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, h } from 'vue'
|
|
import axios from 'axios'
|
|
import { useNavigate } from '../composables/Core/useNavigate'
|
|
import { useModal } from '../composables/Core/useModal'
|
|
import { useUserStore } from '../stores/user'
|
|
import { usePageTitle } from '../composables/Core/usePageTitle'
|
|
import { useUserSettings } from '../composables/useUserSettings'
|
|
import TableDensityToggle from '../Components/Core/TableDensityToggle.vue'
|
|
import SearchBar from '../Components/Core/Search/SearchBar.vue'
|
|
import usePageData from '../composables/usePageData'
|
|
import { useUserTypeLabels } from '../utils/userTypeLabels'
|
|
|
|
const { label: userTypeLabel, badgeClass: userTypeBadgeClass } = useUserTypeLabels()
|
|
|
|
const { navigate } = useNavigate()
|
|
const modal = useModal()
|
|
const userStore = useUserStore()
|
|
const { settings, updateSetting } = useUserSettings();
|
|
const { data: pageUsers, fetchPageData: fetchUsersData } = usePageData()
|
|
usePageTitle('User Management')
|
|
|
|
|
|
const users = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const searchQuery = ref('')
|
|
|
|
// Password reset state
|
|
const resetPasswordUser = ref(null)
|
|
const showResetPasswordModal = ref(false)
|
|
const newPassword = ref('')
|
|
const confirmNewPassword = ref('')
|
|
const resetPasswordLoading = ref(false)
|
|
|
|
const isUltimate = computed(() => userStore.acctType === 'ult')
|
|
|
|
const getUserTypeClass = (type) => userTypeBadgeClass(type)
|
|
|
|
const getUserTypeName = (type) => {
|
|
const name = userTypeLabel(type).toUpperCase()
|
|
return name.includes(' ') ? name.replace(/ /g, '<br>') : name
|
|
}
|
|
|
|
// Initialize component
|
|
onMounted(() => {
|
|
fetchUsers()
|
|
})
|
|
|
|
|
|
|
|
// Check if a user has a null password
|
|
const isNullPasswordUser = (user) => {
|
|
return user.password_null === true || !user.password || user.password === ''
|
|
}
|
|
|
|
|
|
// Skip 91-176 (Reset methods)
|
|
// Fetch all users
|
|
const fetchUsers = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
const result = await fetchUsersData('/admin/users/list', {}, 'GET')
|
|
|
|
// Robust extraction: Handle both { users: [] } object and raw [] array
|
|
const extractedUsers = result?.data?.users || result?.data
|
|
|
|
if (extractedUsers && Array.isArray(extractedUsers)) {
|
|
users.value = extractedUsers
|
|
} else {
|
|
console.error('[UserList] Unexpected data format from /admin/users/list:', result?.data)
|
|
error.value = 'Failed to load users'
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
// Enable user
|
|
const enableUser = (userHash) => {
|
|
modal.continueCancelModal({
|
|
title: 'Enable User',
|
|
body: 'Are you sure you want to enable this user?',
|
|
continueText: 'Enable',
|
|
continueClass: 'btn btn-success w-50 py-2 rounded-3 shadow-sm fw-bold',
|
|
onContinue: async () => {
|
|
try {
|
|
const response = await axios.post('/admin/user/enable', {
|
|
target_user: userHash
|
|
})
|
|
if (response.data) {
|
|
fetchUsers()
|
|
}
|
|
} catch (err) {
|
|
console.error('Error enabling user:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Failed to enable user',
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Disable user
|
|
const disableUser = (userHash) => {
|
|
modal.continueCancelModal({
|
|
title: 'Disable User',
|
|
body: 'Are you sure you want to disable this user?',
|
|
continueText: 'Disable',
|
|
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
|
|
onContinue: async () => {
|
|
try {
|
|
const response = await axios.post('/admin/user/disable', {
|
|
target_user: userHash
|
|
})
|
|
if (response.data) {
|
|
fetchUsers()
|
|
}
|
|
} catch (err) {
|
|
console.error('Error disabling user:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Failed to disable user',
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
// View user details
|
|
const viewUser = async (userHash) => {
|
|
navigate({ page: 'EditUser', props: { id: userHash } })
|
|
}
|
|
|
|
// Navigate to create new user
|
|
const goToCreateUser = () => {
|
|
navigate({ page: 'CreateUser' })
|
|
}
|
|
|
|
|
|
// Open reset password modal
|
|
const openResetPasswordModal = (user) => {
|
|
resetPasswordUser.value = user
|
|
newPassword.value = ''
|
|
confirmNewPassword.value = ''
|
|
showResetPasswordModal.value = true
|
|
}
|
|
|
|
// Close reset password modal
|
|
const closeResetPasswordModal = () => {
|
|
showResetPasswordModal.value = false
|
|
resetPasswordUser.value = null
|
|
newPassword.value = ''
|
|
confirmNewPassword.value = ''
|
|
}
|
|
|
|
// Submit password reset
|
|
const submitPasswordReset = async () => {
|
|
// Validate user is selected before proceeding
|
|
if (!resetPasswordUser?.value) {
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'No user selected for password reset.',
|
|
footer: null
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!newPassword.value || newPassword.value.length < 6) {
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Password must be at least 6 characters.',
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
return
|
|
}
|
|
|
|
if (newPassword.value !== confirmNewPassword.value) {
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Passwords do not match.',
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
resetPasswordLoading.value = true
|
|
const response = await axios.post('/admin/user/password/reset', {
|
|
target_user: resetPasswordUser.value.hashkey,
|
|
user_new_password: newPassword.value
|
|
})
|
|
|
|
if (response.data === true || (response.data && response.data.success)) {
|
|
const userLabel = resetPasswordUser.value?.username || resetPasswordUser.value?.fullname || resetPasswordUser.value?.name || resetPasswordUser.value?.mobile_number || 'User'
|
|
closeResetPasswordModal()
|
|
modal.open({
|
|
title: 'Success',
|
|
body: `Password reset successfully for ${userLabel}`,
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
} else {
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Failed to reset password.',
|
|
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Error resetting password:', err)
|
|
const errorMessage = err.response?.data?.message || err.message || 'Failed to reset password'
|
|
modal.open({
|
|
title: 'Error',
|
|
body: `Failed to reset password: ${errorMessage}`,
|
|
footer: h('button', { class: 'btn btn-secondary mt-3', onClick: modal.close }, 'Close')
|
|
})
|
|
} finally {
|
|
resetPasswordLoading.value = false
|
|
}
|
|
}
|
|
|
|
const tableDensity = computed({
|
|
get: () => settings.value.table_density || 'comfortable',
|
|
set: (val) => updateSetting('table_density', val)
|
|
});
|
|
|
|
const tableDensityClass = computed(() => {
|
|
return {
|
|
'density-comfortable': tableDensity.value === 'comfortable',
|
|
'density-compact': tableDensity.value === 'compact',
|
|
'density-ultra': tableDensity.value === 'ultra-compact'
|
|
}
|
|
})
|
|
|
|
// Filtered users based on search query
|
|
const filteredUsers = computed(() => {
|
|
if (!searchQuery.value) return users.value
|
|
const q = searchQuery.value.toLowerCase()
|
|
return users.value.filter(user => {
|
|
const searchText = [
|
|
user.mobile_number,
|
|
user.name,
|
|
user.fullname,
|
|
user.nickname,
|
|
user.username,
|
|
user.acct_type
|
|
].filter(Boolean).join(' ').toLowerCase()
|
|
return searchText.includes(q)
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="user-list-page pb-5">
|
|
<br><br>
|
|
|
|
<div class="tf-container">
|
|
<!-- Header Toolbar -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-12 col-md-6">
|
|
<h4 class="fw_6 mb-0">Users</h4>
|
|
</div>
|
|
<div class="col-12 col-md-6 d-flex justify-content-md-end align-items-center gap-2">
|
|
<TableDensityToggle v-model="tableDensity" />
|
|
<button
|
|
@click="goToCreateUser"
|
|
class="btn btn-primary d-flex align-items-center gap-2 rounded-pill px-4"
|
|
>
|
|
<i class="fas fa-plus small"></i> Create User
|
|
</button>
|
|
<button
|
|
v-if="isUltimate"
|
|
@click="navigate({ page: 'BatchAddUsers' })"
|
|
class="btn btn-outline-primary d-flex align-items-center gap-2 rounded-pill px-4"
|
|
>
|
|
<i class="fas fa-file-import small"></i> Batch Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Bar -->
|
|
<SearchBar
|
|
v-model="searchQuery"
|
|
id="user-search"
|
|
placeholder="Search users by name, mobile, username..."
|
|
class="mb-4"
|
|
/>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-2">Loading users...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="alert alert-danger">{{ error }}</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="users.length === 0" class="text-center py-5">
|
|
<p>No users found</p>
|
|
<button @click="goToCreateUser" class="btn btn-primary mt-2">Create First User</button>
|
|
</div>
|
|
|
|
<!-- No Results State -->
|
|
<div v-else-if="filteredUsers.length === 0" class="text-center py-5">
|
|
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
|
<p class="text-muted">No users match your search criteria</p>
|
|
<button @click="searchQuery = ''" class="btn btn-outline-primary mt-2">Clear Search</button>
|
|
</div>
|
|
|
|
<!-- Users Table -->
|
|
<div v-else class="card border-0 shadow-sm rounded-20" :data-table-density="tableDensity">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover density-table mb-0" :class="tableDensityClass">
|
|
<thead>
|
|
<tr>
|
|
<th class="bg-transparent">ID</th>
|
|
<th class="bg-transparent">Mobile Number</th>
|
|
<th class="bg-transparent">Name</th>
|
|
<th class="bg-transparent">Username</th>
|
|
<th class="bg-transparent">Type</th>
|
|
<th class="bg-transparent">Status</th>
|
|
<th class="bg-transparent">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="user in filteredUsers" :key="user.hashkey" :class="{ 'bg-warning-lights': isNullPasswordUser(user) }">
|
|
<td>{{ user.id }}</td>
|
|
<td>{{ user.mobile_number || 'N/A' }}</td>
|
|
<td class="text-primary-emphasis fw_6">
|
|
{{ isUltimate ? (user.fullname || user.name || user.nickname || 'N/A') : '******' }}
|
|
</td>
|
|
<td>{{ user.username || '-' }}</td>
|
|
<td>
|
|
<span class="badge" :class="getUserTypeClass(user.acct_type)" v-html="getUserTypeName(user.acct_type)" />
|
|
</td>
|
|
<td>
|
|
<span class="badge" :class="user.is_active ? 'bg-success' : 'bg-danger'">
|
|
{{ user.is_active ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex flex-wrap gap-1" style="max-width: 20vw;">
|
|
<button
|
|
@click="viewUser(user.hashkey)"
|
|
class="btn btn-sm btn-outline-primary p-0 d-flex align-items-center justify-content-center"
|
|
style="width: 26px; height: 26px;"
|
|
title="Edit"
|
|
>
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/58312da31e26.svg" alt="Edit" width="14" height="14">
|
|
</button>
|
|
|
|
<button
|
|
@click="openResetPasswordModal(user)"
|
|
class="btn btn-sm btn-outline-info p-0 d-flex align-items-center justify-content-center"
|
|
style="width: 26px; height: 26px;"
|
|
title="Reset Password"
|
|
>
|
|
<i class="fas fa-key" style="font-size: 14px;"></i>
|
|
</button>
|
|
|
|
<button
|
|
v-if="!user.is_active"
|
|
@click="enableUser(user.hashkey)"
|
|
class="btn btn-sm btn-outline-success p-0 d-flex align-items-center justify-content-center"
|
|
style="width: 26px; height: 26px;"
|
|
title="Enable User"
|
|
>
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7144054b3045.svg" alt="Enable" width="14" height="14">
|
|
</button>
|
|
|
|
<button
|
|
v-else
|
|
@click="disableUser(user.hashkey)"
|
|
class="btn btn-sm btn-outline-warning p-0 d-flex align-items-center justify-content-center"
|
|
style="width: 26px; height: 26px;"
|
|
title="Disable User"
|
|
>
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7006df54c8aa.svg" alt="Disable" width="14" height="14">
|
|
</button>
|
|
|
|
<button
|
|
@click="navigate({ page: 'ManageUser', props: { target: user.hashkey } })"
|
|
class="btn btn-sm btn-outline-secondary p-0 d-flex align-items-center justify-content-center"
|
|
style="width: 26px; height: 26px;"
|
|
title="Manage User Data"
|
|
>
|
|
<i class="fas fa-cog" style="font-size: 14px;"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<nav v-if="filteredUsers.length > 0" aria-label="Page navigation">
|
|
<ul class="pagination justify-content-center">
|
|
<li class="page-item disabled">
|
|
<span class="page-link">1 / {{ Math.ceil(filteredUsers.length / 20) }}</span>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Reset Password Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="showResetPasswordModal" class="modal fade show d-block" role="dialog" @click.self="closeResetPasswordModal">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content reset-password-modal">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-key me-2"></i>Reset Password
|
|
</h5>
|
|
<button type="button" class="btn-close" @click="closeResetPasswordModal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="user-info-banner mb-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="fas fa-user-circle fa-2x text-muted"></i>
|
|
<div>
|
|
<strong>{{ resetPasswordUser?.name || resetPasswordUser?.nickname || 'User' }}</strong>
|
|
<div class="text-muted small">{{ resetPasswordUser?.mobile_number }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="resetNewPassword" class="form-label">New Password</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-lock"></i>
|
|
</span>
|
|
<input
|
|
type="password"
|
|
id="resetNewPassword"
|
|
class="form-control"
|
|
placeholder="Min 6 characters"
|
|
v-model.trim="newPassword"
|
|
:disabled="resetPasswordLoading"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="resetConfirmPassword" class="form-label">Confirm Password</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-lock"></i>
|
|
</span>
|
|
<input
|
|
type="password"
|
|
id="resetConfirmPassword"
|
|
class="form-control"
|
|
placeholder="Confirm new password"
|
|
v-model.trim="confirmNewPassword"
|
|
:disabled="resetPasswordLoading"
|
|
>
|
|
</div>
|
|
<div v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="text-danger small mt-1">
|
|
<i class="fas fa-exclamation-circle me-1"></i>Passwords do not match
|
|
</div>
|
|
<div v-else-if="confirmNewPassword && newPassword === confirmNewPassword && newPassword.length >= 6" class="text-success small mt-1">
|
|
<i class="fas fa-check-circle me-1"></i>Passwords match
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeResetPasswordModal" :disabled="resetPasswordLoading">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
@click="submitPasswordReset"
|
|
:disabled="resetPasswordLoading || !newPassword || newPassword.length < 6 || newPassword !== confirmNewPassword"
|
|
>
|
|
<span v-if="resetPasswordLoading">
|
|
<i class="fas fa-spinner fa-spin me-1"></i>Resetting...
|
|
</span>
|
|
<span v-else>
|
|
<i class="fas fa-key me-1"></i>Reset Password
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="showResetPasswordModal" class="modal-backdrop fade show"></div>
|
|
|
|
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.user-list-page {
|
|
background: var(--bg-primary);
|
|
min-height: 100vh;
|
|
transition: background 0.3s ease;
|
|
}
|
|
|
|
.create-user-icon {
|
|
width: 12px !important;
|
|
height: 12px !important;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: #0085ff;
|
|
border-color: #0085ff;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: #006edb;
|
|
border-color: #006edb;
|
|
}
|
|
|
|
.btn-group .btn + .btn {
|
|
margin-left: 5px;
|
|
}
|
|
|
|
.icon-white {
|
|
filter: brightness(0) invert(1);
|
|
}
|
|
|
|
.btn-group .btn img {
|
|
vertical-align: middle;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.btn-group .btn:hover img {
|
|
transform: scale(1.15);
|
|
}
|
|
|
|
.table {
|
|
border-radius: 12px !important;
|
|
overflow: hidden;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
|
background-color: var(--bg-card);
|
|
border: none;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.table thead th {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--accent-color) !important;
|
|
border-bottom: 2px solid var(--border-color);
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
/* Table Density Styles */
|
|
.density-table thead th,
|
|
.density-table tbody td {
|
|
transition: padding 0.2s ease;
|
|
}
|
|
|
|
/* Comfortable - Default */
|
|
.density-comfortable thead th {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.density-comfortable tbody td {
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Compact */
|
|
.density-compact thead th {
|
|
padding: 0.625rem 0.75rem;
|
|
}
|
|
|
|
.density-compact tbody td {
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
|
|
/* Ultra Compact */
|
|
.density-ultra thead th {
|
|
padding: 0.375rem 0.5rem;
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.density-ultra tbody td {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.badge {
|
|
padding: 6px 12px;
|
|
border-radius: 20px;
|
|
font-weight: 500;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* Reset Password Modal Styles */
|
|
.modal {
|
|
z-index: 1050;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.modal-backdrop {
|
|
z-index: 1040;
|
|
}
|
|
|
|
.modal-dialog {
|
|
z-index: 1060;
|
|
}
|
|
|
|
.reset-password-modal {
|
|
border-radius: 16px;
|
|
border: none;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.reset-password-modal .modal-header {
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 1rem 1.25rem;
|
|
}
|
|
|
|
.reset-password-modal .modal-title {
|
|
color: var(--accent-color);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.reset-password-modal .modal-footer {
|
|
border-top: 1px solid var(--border-color);
|
|
padding: 1rem 1.25rem;
|
|
}
|
|
|
|
.user-info-banner {
|
|
background: var(--bg-secondary);
|
|
border-radius: 10px;
|
|
padding: 12px 16px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.btn-outline-info {
|
|
color: #0085ff;
|
|
border-color: #0085ff;
|
|
}
|
|
|
|
.btn-outline-info:hover {
|
|
background-color: #0085ff;
|
|
border-color: #0085ff;
|
|
color: white;
|
|
}
|
|
</style> |