Add PostgreSQL and Redis integration for users and sessions Implement password and WebAuthn passkey login flows Add Docker stack, super-admin seeding, and protected routes
470 lines
13 KiB
Vue
470 lines
13 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.needsPasskeySetup ? 'Passkey pending' : 'Passkey ready'"
|
|
:color="row.original.needsPasskeySetup ? 'warning' : 'success'"
|
|
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">
|
|
{{ formatDate(row.original.lastLoginAt) }}
|
|
</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. 0123456789"
|
|
/>
|
|
</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 {
|
|
USERNAME_PATTERN,
|
|
isValidPhoneNumber,
|
|
normalizePhoneNumber,
|
|
type ManagedUser,
|
|
type UserRole
|
|
} from '~~/shared/auth'
|
|
|
|
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: '',
|
|
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 = ''
|
|
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 || ''
|
|
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 (state.fullName.trim().length < 2) {
|
|
errors.push({ name: 'fullName', message: 'Enter a display name with at least 2 characters.' })
|
|
}
|
|
|
|
if (!isEditMode.value && !USERNAME_PATTERN.test(state.username.trim().toLowerCase())) {
|
|
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 8 to 15 digits.' })
|
|
}
|
|
|
|
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: error?.data?.statusMessage || '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: userForm.fullName.trim(),
|
|
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
|
role: userForm.role
|
|
}
|
|
})
|
|
|
|
if (editingUserId.value === auth.user.value?.id) {
|
|
await auth.refreshSession()
|
|
}
|
|
|
|
toast.add({
|
|
title: 'User updated',
|
|
description: `${userForm.fullName.trim()} 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: userForm.fullName.trim(),
|
|
username: userForm.username.trim().toLowerCase(),
|
|
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: error?.data?.statusMessage || (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: error?.data?.statusMessage || 'Unable to reset this password.',
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
resettingUserId.value = null
|
|
}
|
|
}
|
|
|
|
function formatDate(value: string | null) {
|
|
if (!value) {
|
|
return 'Never'
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en-MY', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(new Date(value))
|
|
}
|
|
</script>
|