Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
641 lines
19 KiB
Vue
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>
|