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
469 lines
14 KiB
Vue
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>
|