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>