feat: implement auth system, passkeys, and user management

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
This commit is contained in:
2026-04-12 20:16:43 +08:00
parent a649c509c2
commit 377a9617be
45 changed files with 3620 additions and 104 deletions

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
type BookingMode = 'table' | 'pax'
type TicketType = 'vip' | 'supporter'
const toast = useToast()
const apiClient = useApiClient()
const eventDetails = [
{
@@ -50,16 +53,13 @@ const ticketCatalog = [
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
const personInCharge = [
{
label: 'Xiaomai',
value: '601157753558'
},
{
label: 'Lily',
value: '60172661198'
}
]
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
const personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
label: contact.fullName,
value: contact.id
}))
})
const priceFormatter = new Intl.NumberFormat('en-MY', {
style: 'currency',
@@ -76,7 +76,11 @@ const form = reactive({
ticketType: 'vip' as TicketType
})
const selectedPersonInCharge = ref(personInCharge[0]?.value ?? '')
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
const selectedTicket = computed(() => {
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
@@ -101,7 +105,7 @@ function validateBooking(state: typeof form): FormError[] {
if (!state.phone.trim()) {
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
} else if (!/^\+?[0-9\s-]{8,15}$/.test(state.phone.trim())) {
} else if (!isValidPhoneNumber(state.phone.trim())) {
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
}
@@ -129,9 +133,20 @@ function buildBookingMessage() {
}
function bookTicket(event: FormSubmitEvent<typeof form>) {
const selectedPic = personInCharge.find((item) => item.value === selectedPersonInCharge.value) ?? personInCharge[0]
const selectedPic = selectedPersonInChargeRecord.value
if (!selectedPic) {
toast.add({
title: 'No person in charge available',
description: 'Add a user with a phone number in the management page first.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
}
const encodedMessage = encodeURIComponent(buildBookingMessage())
const whatsappUrl = `https://wa.me/${selectedPic.value}?text=${encodedMessage}`
const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}`
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
if (!bookingWindow) {
@@ -146,7 +161,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
toast.add({
title: 'WhatsApp booking draft opened',
description: `Your reservation details were sent to ${selectedPic.label}.`,
description: `Your reservation details were sent to ${selectedPic.fullName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
@@ -217,11 +232,17 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
</div>
<UFormField label="Person In Charge">
<USelect v-model="selectedPersonInCharge" size="xl" class="w-full" :items="personInCharge" />
<USelect
v-model="selectedPersonInCharge"
size="xl"
class="w-full"
:items="personInCharge"
:disabled="!personInCharge.length"
/>
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
class="w-full justify-center" />
class="w-full justify-center" :disabled="!selectedPersonInCharge" />
</UForm>
</UCard>
</div>

View File

@@ -1,11 +1,14 @@
<template>
<UContainer class="py-10">
<UContainer class="py-10 lg:py-16">
<div class="mx-auto max-w-md">
<UCard class="border border-default bg-default">
<UCard class="border border-default bg-default shadow-sm">
<template #header>
<h1 class="text-2xl font-bold text-highlighted">
Staff Login
</h1>
<div class="space-y-2">
<UBadge label="Staff Access" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-3xl font-bold text-highlighted">
Login to the management system
</h1>
</div>
</template>
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
@@ -35,9 +38,29 @@
type="submit"
label="Sign In"
size="xl"
:loading="passwordPending"
class="w-full justify-center"
/>
</UForm>
<div class="my-6 flex items-center gap-3">
<div class="h-px flex-1 bg-default" />
<span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted">or</span>
<div class="h-px flex-1 bg-default" />
</div>
<div class="space-y-4">
<UButton
label="Sign In With Passkey"
color="neutral"
variant="outline"
size="xl"
class="w-full justify-center"
icon="i-lucide-fingerprint"
:loading="passkeyPending"
@click="loginWithPasskey"
/>
</div>
</UCard>
</div>
</UContainer>
@@ -46,13 +69,22 @@
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
definePageMeta({
middleware: 'guest'
})
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const apiClient = useApiClient()
const form = reactive({
username: '',
password: '',
remember: true
})
const passwordPending = ref(false)
const passkeyPending = ref(false)
function validateLogin(state: typeof form): FormError[] {
const errors: FormError[] = []
@@ -68,14 +100,96 @@ function validateLogin(state: typeof form): FormError[] {
return errors
}
function onSubmit(event: FormSubmitEvent<typeof form>) {
toast.add({
title: 'Authentication is not wired yet',
description: 'This page is ready for backend integration, but sign-in is still a placeholder.',
color: 'warning',
icon: 'i-lucide-info'
})
async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>) {
if (!user) {
return
}
const target = user.mustChangePassword || user.needsPasskeySetup
? '/security'
: user.role === 'super_admin'
? '/management/users'
: '/security'
await router.push(target)
}
async function onSubmit(event: FormSubmitEvent<typeof form>) {
event.preventDefault()
if (passwordPending.value) {
return
}
passwordPending.value = true
try {
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/login', {
method: 'POST',
body: {
username: form.username,
password: form.password,
remember: form.remember
}
})
auth.setUser(response.user)
await finishLogin(response.user)
} catch (error: any) {
toast.add({
title: 'Login failed',
description: error?.data?.statusMessage || 'Unable to sign in with username and password.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passwordPending.value = false
}
}
async function loginWithPasskey() {
if (passkeyPending.value) {
return
}
passkeyPending.value = true
try {
const { browserSupportsWebAuthn, startAuthentication } = await import('@simplewebauthn/browser')
if (!browserSupportsWebAuthn()) {
throw new Error('This browser does not support WebAuthn passkeys.')
}
const optionsResponse = await apiClient<{
options: Record<string, any>
challengeToken: string
}>('/api/auth/passkey/login/options', {
method: 'POST'
})
const credential = await startAuthentication({
optionsJSON: optionsResponse.options
})
const verification = await apiClient<{ user: typeof auth.user.value }>('/api/auth/passkey/login/verify', {
method: 'POST',
body: {
response: credential,
challengeToken: optionsResponse.challengeToken,
remember: form.remember
}
})
auth.setUser(verification.user)
await finishLogin(verification.user)
} catch (error: any) {
toast.add({
title: 'Passkey login failed',
description: error?.data?.statusMessage || error?.message || 'Unable to complete passkey login.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passkeyPending.value = false
}
}
</script>

View File

@@ -0,0 +1,469 @@
<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>

View File

@@ -0,0 +1,268 @@
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-5xl space-y-6">
<div class="space-y-2">
<UBadge label="Security" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-3xl font-bold text-highlighted">
Password and passkey settings
</h1>
</div>
<div class="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<UCard class="border border-default bg-default">
<template #header>
<h2 class="text-xl font-semibold text-highlighted">
Change password
</h2>
</template>
<UForm :state="passwordForm" :validate="validatePasswordForm" class="space-y-5" @submit="changePassword">
<UFormField name="currentPassword" label="Current Password" required>
<UInput v-model="passwordForm.currentPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UFormField name="newPassword" label="New Password" required>
<UInput v-model="passwordForm.newPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UFormField name="confirmPassword" label="Confirm New Password" required>
<UInput v-model="passwordForm.confirmPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UButton
type="submit"
label="Update Password"
size="xl"
class="w-full justify-center"
:loading="passwordPending"
/>
</UForm>
</UCard>
<UCard class="border border-default bg-default">
<template #header>
<h2 class="text-xl font-semibold text-highlighted">
Passkeys
</h2>
</template>
<div class="space-y-5">
<div class="rounded-2xl border border-default bg-muted/40 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-highlighted">
Registered passkeys
</div>
<div class="text-sm text-muted">
{{ passkeys.length }} passkey{{ passkeys.length === 1 ? '' : 's' }} connected to this account
</div>
</div>
<UBadge
:label="passkeys.length > 0 ? 'Ready' : 'Required'"
:color="passkeys.length > 0 ? 'success' : 'warning'"
variant="soft"
/>
</div>
</div>
<UButton
label="Register New Passkey"
size="xl"
class="w-full justify-center"
icon="i-lucide-fingerprint"
:loading="passkeyPending"
@click="registerPasskey"
/>
<div v-if="passkeys.length" class="space-y-3">
<div
v-for="passkey in passkeys"
:key="passkey.id"
class="rounded-2xl border border-default bg-default px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-highlighted">
{{ passkey.label }}
</div>
<div class="text-sm text-muted">
Added {{ formatDate(passkey.createdAt) }}
</div>
</div>
<UBadge :label="passkey.deviceType === 'multiDevice' ? 'Synced' : 'Single Device'" color="neutral" variant="soft" />
</div>
</div>
</div>
</div>
</UCard>
</div>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { MIN_PASSWORD_LENGTH, type PasskeySummary } from '~~/shared/auth'
definePageMeta({
middleware: 'auth'
})
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const apiClient = useApiClient()
const passwordPending = ref(false)
const passkeyPending = ref(false)
const passkeys = ref<PasskeySummary[]>([])
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
await fetchPasskeys()
function validatePasswordForm(state: typeof passwordForm): FormError[] {
const errors: FormError[] = []
if (!state.currentPassword.trim()) {
errors.push({ name: 'currentPassword', message: 'Enter your current password.' })
}
if (state.newPassword.trim().length < MIN_PASSWORD_LENGTH) {
errors.push({ name: 'newPassword', message: `Use at least ${MIN_PASSWORD_LENGTH} characters.` })
}
if (state.confirmPassword.trim() !== state.newPassword.trim()) {
errors.push({ name: 'confirmPassword', message: 'Confirmation does not match the new password.' })
}
return errors
}
async function fetchPasskeys() {
const response = await apiClient<{ passkeys: PasskeySummary[] }>('/api/auth/passkeys')
passkeys.value = response.passkeys
}
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
if (previouslyRequired && !auth.needsOnboarding.value) {
router.push(auth.isSuperAdmin.value ? '/management/users' : '/security')
}
}
async function changePassword(event: FormSubmitEvent<typeof passwordForm>) {
event.preventDefault()
if (passwordPending.value) {
return
}
passwordPending.value = true
const previouslyRequired = auth.needsOnboarding.value
try {
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/change-password', {
method: 'POST',
body: {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
}
})
auth.setUser(response.user)
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
toast.add({
title: 'Password updated',
description: 'Your account password has been changed.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
maybeRedirectAfterOnboarding(previouslyRequired)
} catch (error: any) {
toast.add({
title: 'Password update failed',
description: error?.data?.statusMessage || 'Unable to update your password.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passwordPending.value = false
}
}
async function registerPasskey() {
if (passkeyPending.value) {
return
}
passkeyPending.value = true
const previouslyRequired = auth.needsOnboarding.value
try {
const { browserSupportsWebAuthn, startRegistration } = await import('@simplewebauthn/browser')
if (!browserSupportsWebAuthn()) {
throw new Error('This browser does not support passkey registration.')
}
const optionsResponse = await apiClient<{ options: Record<string, any> }>('/api/auth/passkey/register/options', {
method: 'POST'
})
const credential = await startRegistration({
optionsJSON: optionsResponse.options
})
const verification = await apiClient<{
user: typeof auth.user.value
passkeys: PasskeySummary[]
}>('/api/auth/passkey/register/verify', {
method: 'POST',
body: {
response: credential
}
})
auth.setUser(verification.user)
passkeys.value = verification.passkeys
toast.add({
title: 'Passkey registered',
description: 'You can now use this passkey to sign in.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
maybeRedirectAfterOnboarding(previouslyRequired)
} catch (error: any) {
toast.add({
title: 'Passkey registration failed',
description: error?.data?.statusMessage || error?.message || 'Unable to register a passkey.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passkeyPending.value = false
}
}
function formatDate(value: string | null) {
if (!value) {
return 'Not available'
}
return new Intl.DateTimeFormat('en-MY', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
}
</script>