Files
dticket.tootaio.com/app/pages/management/users/index.vue
xiaomai 06165f80db feat(auth): make passkey enrollment optional on first login
Remove passkey requirement from user onboarding flow
Update UI badges to show passkeys as optional rather than pending
Update documentation to reflect the new behavior
2026-04-27 13:25:05 +08:00

469 lines
14 KiB
Vue

<template>
<UContainer class="py-8">
<div class="mx-auto max-w-6xl space-y-4">
<div class="space-y-1.5">
<UBadge label="Super Admin" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold text-highlighted sm:text-3xl">
User management
</h1>
</div>
<UAlert
v-if="issuedPasswordMessage"
title="Temporary password issued"
:description="issuedPasswordMessage"
color="success"
icon="i-lucide-key-round"
/>
<UCard class="border border-default bg-default shadow-sm" :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">
<UTable
:data="filteredUsers"
:columns="columns"
:loading="loadingUsers"
:empty="searchQuery.trim() ? 'No matching users found.' : 'No users available.'"
sticky="header"
caption="Users"
class="min-w-[820px]"
>
<template #fullName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-1">
<div class="font-semibold leading-tight text-highlighted">
{{ row.original.fullName }}
</div>
<div class="text-xs text-muted">
@{{ row.original.username }}
</div>
</div>
</template>
<template #phoneNumber-cell="{ row }">
<span class="text-sm" :class="row.original.phoneNumber ? 'text-default' : 'text-muted'">
{{ row.original.phoneNumber || 'Not set' }}
</span>
</template>
<template #role-cell="{ row }">
<UBadge
:label="row.original.role === 'super_admin' ? 'Super Admin' : 'Staff'"
:color="row.original.role === 'super_admin' ? 'primary' : 'neutral'"
variant="soft"
/>
</template>
<template #status-cell="{ row }">
<div class="space-y-1.5 py-1">
<div class="flex flex-wrap gap-1.5">
<UBadge
:label="row.original.mustChangePassword ? 'Password reset' : 'Password ready'"
:color="row.original.mustChangePassword ? 'warning' : 'success'"
variant="soft"
size="sm"
/>
<UBadge
:label="row.original.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
:color="row.original.passkeyCount > 0 ? 'success' : 'neutral'"
variant="soft"
size="sm"
/>
</div>
<div class="text-xs text-muted">
{{ row.original.passkeyCount }} passkey{{ row.original.passkeyCount === 1 ? '' : 's' }}
</div>
</div>
</template>
<template #lastLoginAt-cell="{ row }">
<span class="text-xs text-muted sm:text-sm">
{{ formatDateTime(row.original.lastLoginAt, 'Never') }}
</span>
</template>
<template #actions-cell="{ row }">
<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(row.original)"
/>
<UButton
label="Reset Password"
color="neutral"
variant="outline"
icon="i-lucide-key-round"
size="sm"
:loading="resettingUserId === row.original.id"
@click="resetPassword(row.original)"
/>
</div>
</template>
</UTable>
</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 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 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 columns = [
{ accessorKey: 'fullName', header: 'Display Name' },
{ accessorKey: 'phoneNumber', header: 'PIC Phone' },
{ accessorKey: 'role', header: 'Role' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'lastLoginAt', header: 'Last Login' },
{ id: 'actions', header: 'Actions' }
]
const isEditMode = computed(() => editorMode.value === 'edit')
const isEditingCurrentUser = computed(() => {
return isEditMode.value && editingUserId.value === auth.user.value?.id
})
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
}
}
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>