Files
dticket.tootaio.com/app/pages/management/users/index.vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

641 lines
19 KiB
Vue

<template>
<UContainer class="page-shell">
<div class="space-y-5">
<div class="page-header">
<UBadge label="Super Admin" color="primary" variant="soft" class="page-eyebrow" />
<h1 class="page-title">
User management
</h1>
</div>
<UAlert
v-if="issuedPasswordMessage"
title="Temporary password issued"
:description="issuedPasswordMessage"
color="success"
icon="i-lucide-key-round"
/>
<UCard class="surface-card overflow-hidden rounded-lg" :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<div class="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UInput
v-model="searchQuery"
size="lg"
class="w-full sm:w-72"
placeholder="Search name, username, or phone"
/>
<UButton
label="Refresh"
color="neutral"
variant="outline"
icon="i-lucide-refresh-cw"
size="lg"
:loading="loadingUsers"
@click="refreshUsers"
/>
</div>
<UButton
label="Add User"
size="lg"
icon="i-lucide-plus"
class="justify-center"
@click="openCreateModal"
/>
</div>
</template>
<div class="overflow-x-auto">
<div class="compact-table min-w-[900px]" aria-label="Users">
<div class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 border-b border-default bg-muted px-4 py-3 text-xs font-semibold uppercase text-muted">
<div>Order</div>
<div>Display Name</div>
<div>PIC Phone</div>
<div>Role</div>
<div>Status</div>
<div>Last Login</div>
<div class="text-right">Actions</div>
</div>
<TransitionGroup tag="div" name="user-list" class="divide-y divide-default">
<div
v-for="user in filteredUsers"
:key="user.id"
:draggable="canReorderUsers"
class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 bg-default px-4 py-3 transition-[background,box-shadow,transform,opacity] duration-200"
:class="{
'cursor-grab hover:bg-muted/60': canReorderUsers,
'cursor-not-allowed opacity-70': !canReorderUsers,
'scale-[0.99] opacity-60 shadow-lg': draggedUserId === user.id,
'bg-muted': dragOverUserId === user.id
}"
@dragstart="startUserDrag($event, user)"
@dragover.prevent="overUserDrag($event, user)"
@drop.prevent="dropUserDrag"
@dragend="endUserDrag"
>
<div class="flex items-center">
<UTooltip :text="canReorderUsers ? 'Drag to reorder' : 'Clear search to reorder'">
<button
type="button"
class="inline-flex size-9 items-center justify-center rounded-md border border-default bg-default text-muted transition hover:text-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="canReorderUsers ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed opacity-50'"
:aria-label="`Drag ${user.fullName} to reorder PIC list`"
:disabled="!canReorderUsers"
>
<UIcon name="i-lucide-grip-vertical" class="size-4" />
</button>
</UTooltip>
</div>
<div class="min-w-0 space-y-0.5 py-1">
<div class="font-semibold leading-tight text-highlighted">
{{ user.fullName }}
</div>
<div class="text-xs text-muted">
@{{ user.username }}
</div>
</div>
<span class="text-sm" :class="user.phoneNumber ? 'text-default' : 'text-muted'">
{{ user.phoneNumber || 'Not set' }}
</span>
<div>
<UBadge
:label="user.role === 'super_admin' ? 'Super Admin' : 'Staff'"
:color="user.role === 'super_admin' ? 'primary' : 'neutral'"
variant="soft"
/>
</div>
<div class="space-y-1.5 py-1">
<div class="flex flex-wrap gap-1.5">
<UBadge
:label="user.mustChangePassword ? 'Password reset' : 'Password ready'"
:color="user.mustChangePassword ? 'warning' : 'success'"
variant="soft"
size="sm"
/>
<UBadge
:label="user.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
:color="user.passkeyCount > 0 ? 'success' : 'neutral'"
variant="soft"
size="sm"
/>
</div>
<div class="text-xs text-muted">
{{ user.passkeyCount }} passkey{{ user.passkeyCount === 1 ? '' : 's' }}
</div>
</div>
<span class="text-xs text-muted sm:text-sm">
{{ formatDateTime(user.lastLoginAt, 'Never') }}
</span>
<div class="flex flex-wrap justify-end gap-1.5 py-1">
<UButton
label="Edit"
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
size="sm"
@click="openEditModal(user)"
/>
<UButton
label="Reset Password"
color="neutral"
variant="outline"
icon="i-lucide-key-round"
size="sm"
:loading="resettingUserId === user.id"
@click="resetPassword(user)"
/>
</div>
</div>
</TransitionGroup>
<div v-if="!filteredUsers.length" class="px-4 py-10 text-center text-sm text-muted">
{{ searchQuery.trim() ? 'No matching users found.' : 'No users available.' }}
</div>
</div>
</div>
</UCard>
<UModal
v-model:open="editorOpen"
:title="isEditMode ? 'Edit User' : 'Add User'"
:dismissible="!savingUser"
:close="!savingUser"
:content="{ class: 'sm:max-w-xl' }"
>
<template #body>
<UForm :state="userForm" :validate="validateUserForm" class="space-y-4" @submit="saveUser">
<UFormField name="fullName" label="Display Name" required>
<UInput v-model="userForm.fullName" size="lg" class="w-full" />
</UFormField>
<UFormField name="username" label="Username" required>
<UInput
v-model="userForm.username"
size="lg"
class="w-full"
:disabled="isEditMode"
/>
</UFormField>
<UFormField name="phoneNumber" label="Phone Number" required>
<UInput
v-model="userForm.phoneNumber"
size="lg"
type="tel"
class="w-full"
placeholder="e.g. +60123456789"
/>
</UFormField>
<UFormField name="role" label="Role" required>
<USelect
v-model="userForm.role"
size="lg"
class="w-full"
:items="roleOptions"
:disabled="isEditingCurrentUser"
/>
</UFormField>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
label="Cancel"
color="neutral"
variant="ghost"
class="justify-center"
:disabled="savingUser"
@click="closeEditor"
/>
<UButton
type="submit"
:label="isEditMode ? 'Save Changes' : 'Create User'"
class="justify-center"
:loading="savingUser"
/>
</div>
</UForm>
</template>
</UModal>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import {
DEFAULT_PHONE_COUNTRY_CODE,
hasValidFullName,
isValidPhoneNumber,
isValidUsername,
normalizeFullName,
normalizePhoneNumber,
normalizeUsername,
type ManagedUser,
type UserRole
} from '~~/shared/auth'
import { getErrorMessage } from '../../../utils/errors'
import { formatDateTime } from '../../../utils/formatters'
definePageMeta({
middleware: 'super-admin'
})
const toast = useToast()
const apiClient = useApiClient()
const auth = useAuth()
const users = ref<ManagedUser[]>([])
const loadingUsers = ref(false)
const savingUser = ref(false)
const savingPicOrder = ref(false)
const resettingUserId = ref<string | null>(null)
const issuedPasswordMessage = ref('')
const searchQuery = ref('')
const editorOpen = ref(false)
const editorMode = ref<'create' | 'edit'>('create')
const editingUserId = ref<string | null>(null)
const draggedUserId = ref<string | null>(null)
const dragOverUserId = ref<string | null>(null)
const dragSaveStarted = ref(false)
let usersBeforeDrag: ManagedUser[] = []
const userForm = reactive({
fullName: '',
username: '',
phoneNumber: DEFAULT_PHONE_COUNTRY_CODE,
role: 'staff' as UserRole
})
const roleOptions = [
{ label: 'Staff', value: 'staff' },
{ label: 'Super Admin', value: 'super_admin' }
]
const isEditMode = computed(() => editorMode.value === 'edit')
const isEditingCurrentUser = computed(() => {
return isEditMode.value && editingUserId.value === auth.user.value?.id
})
const canReorderUsers = computed(() => {
return !searchQuery.value.trim() && !loadingUsers.value && !savingPicOrder.value
})
const filteredUsers = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) {
return users.value
}
return users.value.filter((user) => {
return [
user.fullName,
user.username,
user.phoneNumber || '',
user.role
].some((value) => value.toLowerCase().includes(keyword))
})
})
await refreshUsers()
function resetUserForm() {
userForm.fullName = ''
userForm.username = ''
userForm.phoneNumber = DEFAULT_PHONE_COUNTRY_CODE
userForm.role = 'staff'
editingUserId.value = null
}
function openCreateModal() {
editorMode.value = 'create'
resetUserForm()
editorOpen.value = true
}
function openEditModal(user: ManagedUser) {
editorMode.value = 'edit'
editingUserId.value = user.id
userForm.fullName = user.fullName
userForm.username = user.username
userForm.phoneNumber = user.phoneNumber ? normalizePhoneNumber(user.phoneNumber) : DEFAULT_PHONE_COUNTRY_CODE
userForm.role = user.role
editorOpen.value = true
}
function closeEditor() {
if (savingUser.value) {
return
}
editorOpen.value = false
resetUserForm()
}
function validateUserForm(state: typeof userForm): FormError[] {
const errors: FormError[] = []
if (!hasValidFullName(state.fullName)) {
errors.push({ name: 'fullName', message: 'Enter a display name with at least 2 characters.' })
}
if (!isEditMode.value && !isValidUsername(state.username)) {
errors.push({ name: 'username', message: 'Use 3 to 32 lowercase letters, numbers, dot, dash, or underscore.' })
}
if (!isValidPhoneNumber(state.phoneNumber)) {
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
return errors
}
async function refreshUsers() {
if (loadingUsers.value) {
return
}
loadingUsers.value = true
try {
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users')
users.value = response.users
} catch (error: any) {
toast.add({
title: 'Unable to load users',
description: getErrorMessage(error, 'The user list could not be loaded.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingUsers.value = false
}
}
function moveUserNearTarget(sourceUserId: string, targetUserId: string, insertAfterTarget: boolean) {
if (sourceUserId === targetUserId) {
return
}
const sourceIndex = users.value.findIndex((user) => user.id === sourceUserId)
const currentUserIds = users.value.map((user) => user.id)
if (sourceIndex === -1 || !currentUserIds.includes(targetUserId)) {
return
}
const reorderedUsers = [...users.value]
const [movedUser] = reorderedUsers.splice(sourceIndex, 1)
const targetIndex = reorderedUsers.findIndex((user) => user.id === targetUserId)
if (targetIndex === -1) {
return
}
reorderedUsers.splice(insertAfterTarget ? targetIndex + 1 : targetIndex, 0, movedUser)
const nextUserIds = reorderedUsers.map((user) => user.id)
if (nextUserIds.join('|') === currentUserIds.join('|')) {
return
}
users.value = reorderedUsers
}
function resetDragState() {
draggedUserId.value = null
dragOverUserId.value = null
dragSaveStarted.value = false
usersBeforeDrag = []
}
function startUserDrag(event: DragEvent, user: ManagedUser) {
if (!canReorderUsers.value) {
event.preventDefault()
return
}
draggedUserId.value = user.id
dragOverUserId.value = user.id
dragSaveStarted.value = false
usersBeforeDrag = [...users.value]
event.dataTransfer?.setData('text/plain', user.id)
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function overUserDrag(event: DragEvent, user: ManagedUser) {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
if (!canReorderUsers.value || !draggedUserId.value || draggedUserId.value === user.id) {
return
}
const target = event.currentTarget
if (!(target instanceof HTMLElement)) {
return
}
const rect = target.getBoundingClientRect()
const insertAfterTarget = event.clientY > rect.top + rect.height / 2
dragOverUserId.value = user.id
moveUserNearTarget(draggedUserId.value, user.id, insertAfterTarget)
}
async function dropUserDrag() {
if (!draggedUserId.value || savingPicOrder.value) {
return
}
dragSaveStarted.value = true
savingPicOrder.value = true
try {
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users/order', {
method: 'PATCH',
body: {
userIds: users.value.map((user) => user.id)
}
})
users.value = response.users
toast.add({
title: 'PIC order saved',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
users.value = usersBeforeDrag
toast.add({
title: 'PIC order could not be saved',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingPicOrder.value = false
resetDragState()
}
}
function endUserDrag() {
if (dragSaveStarted.value) {
return
}
if (draggedUserId.value && usersBeforeDrag.length) {
users.value = usersBeforeDrag
}
resetDragState()
}
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
event.preventDefault()
if (savingUser.value) {
return
}
savingUser.value = true
try {
if (isEditMode.value && editingUserId.value) {
await apiClient(`/api/admin/users/${editingUserId.value}`, {
method: 'PATCH',
body: {
fullName: normalizeFullName(userForm.fullName),
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
role: userForm.role
}
})
if (editingUserId.value === auth.user.value?.id) {
await auth.refreshSession()
}
toast.add({
title: 'User updated',
description: `${normalizeFullName(userForm.fullName)} has been updated.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} else {
const response = await apiClient<{
user: ManagedUser
defaultPassword: string
}>('/api/admin/users', {
method: 'POST',
body: {
fullName: normalizeFullName(userForm.fullName),
username: normalizeUsername(userForm.username),
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
role: userForm.role
}
})
issuedPasswordMessage.value = `${response.user.fullName} was created with the temporary password ${response.defaultPassword}.`
toast.add({
title: 'User created',
description: `${response.user.fullName} can now sign in with the temporary password.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
}
closeEditor()
await refreshUsers()
} catch (error: any) {
toast.add({
title: isEditMode.value ? 'Update failed' : 'User creation failed',
description: getErrorMessage(
error,
isEditMode.value ? 'Unable to update this user.' : 'Unable to create the new user.'
),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingUser.value = false
}
}
async function resetPassword(user: ManagedUser) {
if (resettingUserId.value) {
return
}
resettingUserId.value = user.id
try {
const response = await apiClient<{
user: ManagedUser
defaultPassword: string
}>(`/api/admin/users/${user.id}/reset-password`, {
method: 'POST'
})
issuedPasswordMessage.value = `${user.fullName}'s password was reset to ${response.defaultPassword}. They must change it after login.`
toast.add({
title: 'Password reset',
description: `${user.fullName} now has a fresh temporary password.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
await refreshUsers()
} catch (error: any) {
toast.add({
title: 'Reset failed',
description: getErrorMessage(error, 'Unable to reset this password.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
resettingUserId.value = null
}
}
</script>
<style scoped>
.user-list-move,
.user-list-enter-active,
.user-list-leave-active {
transition: transform 180ms ease, opacity 160ms ease, background-color 160ms ease;
}
.user-list-enter-from,
.user-list-leave-to {
opacity: 0;
transform: translateY(6px);
}
.user-list-leave-active {
position: absolute;
width: 100%;
}
</style>