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

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.gitignore
.nuxt
.output
node_modules
.env
.env.*
!.env.example
README.md
Dockerfile*
docker-compose*.yml
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
# Use your deployed HTTPS origin in production so WebAuthn/passkeys validate correctly.
NUXT_PUBLIC_APP_URL=http://localhost:20013
NUXT_PUBLIC_RP_NAME=Dinner Ticket System
NITRO_HOST=0.0.0.0
PORT=20013

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:22-alpine AS base
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm build
FROM base AS prod-deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --prod
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV PORT=20013
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/.output ./.output
COPY --from=build /app/package.json ./package.json
EXPOSE 20013
CMD ["node", ".output/server/index.mjs"]

144
README.md
View File

@@ -1,75 +1,121 @@
# Nuxt Minimal Starter
# Dinner Ticket System
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
Nuxt 4 app with:
- Public dinner ticket booking page
- Staff login with password and passkey support
- PostgreSQL-backed users and passkeys
- Redis-backed sessions and WebAuthn challenge storage
- Seeded `xiaomai` super-admin account
- Super-admin user creation and password reset flow
- First-login enforcement: temporary password change plus passkey enrollment
## Environment
Create `.env` from `.env.example` and set:
```bash
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379
NUXT_PUBLIC_APP_URL=http://localhost:20013
```
`NUXT_PUBLIC_APP_URL` should be your final HTTPS origin in production. Passkeys rely on the RP origin being stable and correct.
## Setup
Make sure to install dependencies:
Install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
## Development
Start the development server on `http://localhost:3000`:
Start the app:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
The backend bootstraps its schema automatically on startup and seeds this initial super-admin account if it does not already exist:
- Username: `xiaomai`
- Temporary password: `123456`
On first login, the user is forced to change that temporary password and register a passkey before accessing the protected area.
## Production
Build the application for production:
Build:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
Preview the built server:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
node .output/server/index.mjs
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
## Docker
The repo now includes a production-ready container stack:
- [Dockerfile](/mnt/d/SourceCode/tootaio/dinner-ticket-system/Dockerfile)
- [docker-compose.yml](/mnt/d/SourceCode/tootaio/dinner-ticket-system/docker-compose.yml)
- [.dockerignore](/mnt/d/SourceCode/tootaio/dinner-ticket-system/.dockerignore)
Bring up the full environment:
```bash
docker compose up --build
```
This starts:
- Nuxt/Nitro app on `http://localhost:20013`
- PostgreSQL only on the internal Docker network
- Redis only on the internal Docker network
The app container waits on PostgreSQL and Redis health checks, and exposes:
- `GET /api/health` for container/runtime health
Stop the stack:
```bash
docker compose down
```
Stop and remove persisted database/cache volumes:
```bash
docker compose down -v
```
For passkey testing in Docker, set `NUXT_PUBLIC_APP_URL` to the exact origin you open in the browser. In production, this should be your final HTTPS URL.
## Protected Areas
- `/login`
- `/security`
- `/management/users`
## User Flows
- Password login with Redis-backed session cookie
- Passkey login using WebAuthn discoverable credentials
- Super admin creates users with default password `123456`
- Users must change password and set a passkey after first login
- Users can change their own password from Security
- Super admin can reset a user's password back to `123456`
## Verification
The codebase currently verifies cleanly with:
```bash
pnpm build
```

View File

@@ -0,0 +1,10 @@
export function useApiClient() {
return async function apiClient<T>(url: string, options?: Parameters<typeof $fetch<T>>[1]) {
if (import.meta.server) {
const requestFetch = useRequestFetch()
return await requestFetch<T>(url, options)
}
return await $fetch<T>(url, options)
}
}

View File

@@ -0,0 +1,54 @@
import type { AuthUser } from '~~/shared/auth'
export function useAuth() {
const user = useState<AuthUser | null>('auth:user', () => null)
const loaded = useState<boolean>('auth:loaded', () => false)
const loading = useState<boolean>('auth:loading', () => false)
const apiClient = useApiClient()
const isAuthenticated = computed(() => Boolean(user.value))
const isSuperAdmin = computed(() => user.value?.role === 'super_admin')
const needsOnboarding = computed(() => {
return Boolean(user.value && (user.value.mustChangePassword || user.value.needsPasskeySetup))
})
async function fetchSession(force = false) {
if (loaded.value && !force) {
return user.value
}
loading.value = true
try {
const response = await apiClient<{ user: AuthUser | null }>('/api/auth/me')
user.value = response.user
loaded.value = true
return user.value
} finally {
loading.value = false
}
}
function setUser(nextUser: AuthUser | null) {
user.value = nextUser
loaded.value = true
}
function clearUser() {
user.value = null
loaded.value = true
}
return {
user,
loaded,
loading,
isAuthenticated,
isSuperAdmin,
needsOnboarding,
fetchSession,
refreshSession: () => fetchSession(true),
setUser,
clearUser
}
}

View File

@@ -1,37 +1,416 @@
<template>
<div class="min-h-dvh bg-default text-default">
<header class="border-b border-default bg-default">
<UContainer class="flex items-center justify-between gap-4 py-6">
<UBadge
label="Event Ticket System"
color="neutral"
variant="soft"
class="rounded-full px-3 py-1 font-semibold"
<div class="relative min-h-dvh bg-default text-default">
<div class="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-primary/10 via-primary/0 to-transparent opacity-80" />
<template v-if="auth.user.value">
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<button
v-if="mobileMenuOpen"
type="button"
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden"
aria-label="Close navigation"
@click="mobileMenuOpen = false"
/>
</Transition>
<UButton
id="loginBtn"
:to="route.path.startsWith('/login') ? '/' : '/login'"
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
color="neutral"
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
/>
</UContainer>
</header>
<Transition
enter-active-class="transform transition duration-300 ease-out"
enter-from-class="-translate-x-6 opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transform transition duration-200 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="-translate-x-6 opacity-0"
>
<aside
v-if="mobileMenuOpen"
class="fixed inset-y-2 left-2 z-50 flex w-[min(20rem,calc(100vw-1rem))] flex-col rounded-[28px] border border-default/80 bg-default/96 p-3 shadow-2xl backdrop-blur-xl lg:hidden"
>
<div class="flex items-start justify-between gap-3 rounded-3xl border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4">
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<UMain>
<slot />
</UMain>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
<footer class="border-t border-default bg-default">
<UContainer class="py-5 text-center text-sm text-muted">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
</UContainer>
</footer>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
class="rounded-full"
aria-label="Close navigation"
@click="mobileMenuOpen = false"
/>
</div>
<nav class="mt-4 grid gap-2">
<NuxtLink
v-for="item in systemMenuItems"
:key="item.to"
:to="item.to"
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
:class="mobileMenuItemClasses(item)"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
:class="mobileMenuIconClasses(item)"
>
<UIcon :name="item.icon" class="size-5" />
</div>
<span class="min-w-0 truncate">{{ item.label }}</span>
<UIcon name="i-lucide-chevron-right" class="ml-auto size-4 opacity-50 transition-opacity duration-200 group-hover:opacity-100" />
</NuxtLink>
</nav>
<div class="mt-auto space-y-3 border-t border-default pt-3">
<div class="rounded-3xl border border-default bg-default/90 px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
<UIcon name="i-lucide-user-round" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
<div class="truncate text-sm text-muted">
@{{ auth.user.value.username }}
</div>
</div>
</div>
<UBadge
:label="userRoleLabel"
color="primary"
variant="soft"
class="mt-3 rounded-full"
/>
</div>
<UButton
to="/"
label="Public"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="w-full justify-start rounded-2xl"
@click="mobileMenuOpen = false"
/>
<UButton
:loading="logoutPending"
label="Logout"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
class="w-full justify-start rounded-2xl"
@click="logout"
/>
</div>
</aside>
</Transition>
<div class="relative lg:grid lg:min-h-dvh lg:grid-cols-[17.5rem_minmax(0,1fr)]">
<aside class="sticky top-0 hidden h-dvh flex-col border-r border-default/80 bg-default/92 px-5 py-5 backdrop-blur-xl lg:flex">
<div class="rounded-[28px] border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-6" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
</div>
<nav class="mt-6 grid gap-2">
<NuxtLink
v-for="item in systemMenuItems"
:key="item.to"
:to="item.to"
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
:class="desktopMenuItemClasses(item)"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
:class="desktopMenuIconClasses(item)"
>
<UIcon :name="item.icon" class="size-5" />
</div>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<div
class="h-2.5 w-2.5 rounded-full transition-all duration-200"
:class="isMenuItemActive(item) ? 'bg-primary shadow-[0_0_0_4px_rgba(var(--ui-primary-rgb),0.14)]' : 'bg-default group-hover:bg-primary/30'"
/>
</NuxtLink>
</nav>
<div class="mt-auto space-y-3">
<div class="rounded-[28px] border border-default bg-default/90 px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
<UIcon name="i-lucide-user-round" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
<div class="truncate text-sm text-muted">
@{{ auth.user.value.username }}
</div>
</div>
</div>
<UBadge
:label="userRoleLabel"
color="primary"
variant="soft"
class="mt-3 rounded-full"
/>
</div>
<div class="grid gap-2">
<UButton
to="/"
label="Public"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="justify-start rounded-2xl"
/>
<UButton
:loading="logoutPending"
label="Logout"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
class="justify-start rounded-2xl"
@click="logout"
/>
</div>
</div>
</aside>
<div class="min-w-0">
<header class="sticky top-0 z-30 border-b border-default/80 bg-default/92 backdrop-blur-xl lg:hidden">
<UContainer class="py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
<div class="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
<UButton
color="neutral"
variant="outline"
class="shrink-0 rounded-full"
:label="mobileMenuOpen ? 'Close' : 'Menu'"
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
:aria-expanded="mobileMenuOpen"
aria-label="Toggle navigation"
@click="mobileMenuOpen = !mobileMenuOpen"
/>
</div>
</UContainer>
</header>
<UMain class="relative z-10">
<slot />
</UMain>
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
</UContainer>
</footer>
</div>
</div>
</template>
<template v-else>
<header class="relative border-b border-default/80 bg-default/92 backdrop-blur-xl">
<UContainer class="py-4 md:py-5">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
Dinner Ticket System
</div>
</div>
</div>
<UButton
id="loginBtn"
:to="route.path.startsWith('/login') ? '/' : '/login'"
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
color="neutral"
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
class="rounded-full"
/>
</div>
</UContainer>
</header>
<UMain class="relative z-10">
<slot />
</UMain>
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
</UContainer>
</footer>
</template>
</div>
</template>
<script lang="ts" setup>
interface SystemMenuItem {
label: string
to: string
icon: string
requiresSuperAdmin?: boolean
matches: (path: string) => boolean
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const auth = useAuth()
const apiClient = useApiClient()
const logoutPending = ref(false)
const mobileMenuOpen = ref(false)
await auth.fetchSession()
const allSystemMenuItems: SystemMenuItem[] = [
{
label: 'Security',
to: '/security',
icon: 'i-lucide-shield-check',
matches: (path) => path.startsWith('/security')
},
{
label: 'Users',
to: '/management/users',
icon: 'i-lucide-users',
requiresSuperAdmin: true,
matches: (path) => path.startsWith('/management/users')
}
]
const systemMenuItems = computed(() => {
return allSystemMenuItems.filter((item) => {
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
})
})
const userRoleLabel = computed(() => {
return auth.isSuperAdmin.value ? 'Super Admin' : 'Staff'
})
function isMenuItemActive(item: SystemMenuItem) {
return item.matches(route.path)
}
function desktopMenuItemClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
: 'border-default bg-default/80 text-default hover:border-primary/20 hover:bg-muted/60 hover:text-highlighted'
}
function desktopMenuIconClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted group-hover:bg-primary/10 group-hover:text-primary'
}
function mobileMenuItemClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
: 'border-default bg-default/88 text-default'
}
function mobileMenuIconClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted'
}
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
async function logout() {
if (logoutPending.value) {
return
}
logoutPending.value = true
try {
await apiClient('/api/auth/logout', {
method: 'POST'
})
auth.clearUser()
await router.push('/login')
toast.add({
title: 'Signed out',
description: 'Your session has been cleared.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Logout failed',
description: error?.data?.statusMessage || 'Unable to end the current session.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
logoutPending.value = false
}
}
</script>

12
app/middleware/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuth()
await auth.fetchSession()
if (!auth.user.value) {
return navigateTo('/login')
}
if (to.path !== '/security' && auth.needsOnboarding.value) {
return navigateTo('/security')
}
})

14
app/middleware/guest.ts Normal file
View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuth()
await auth.fetchSession()
if (!auth.user.value) {
return
}
if (auth.needsOnboarding.value) {
return navigateTo('/security')
}
return navigateTo(auth.isSuperAdmin.value ? '/management/users' : '/security')
})

View File

@@ -0,0 +1,16 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuth()
await auth.fetchSession()
if (!auth.user.value) {
return navigateTo('/login')
}
if (auth.needsOnboarding.value) {
return navigateTo('/security')
}
if (!auth.isSuperAdmin.value) {
return navigateTo('/security')
}
})

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>

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
services:
postgres:
image: postgres:17-alpine
container_name: dinner-ticket-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dinner_ticket_system
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d dinner_ticket_system"]
interval: 5s
timeout: 5s
retries: 20
start_period: 5s
redis:
image: redis:7-alpine
container_name: dinner-ticket-redis
restart: unless-stopped
volumes:
- redis_data:/data
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
app:
build:
context: .
dockerfile: Dockerfile
container_name: dinner-ticket-app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: production
NITRO_HOST: 0.0.0.0
PORT: 20013
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
NUXT_REDIS_URL: redis://redis:6379
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
ports:
- "20013:20013"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20013/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
postgres_data:
redis_data:

View File

@@ -6,6 +6,15 @@ export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
databaseUrl: '',
redisUrl: '',
sessionCookieName: 'dinner_ticket_session',
public: {
appUrl: '',
rpName: 'Dinner Ticket System'
}
},
vite: {
plugins: [tailwindcss()]
}

View File

@@ -7,11 +7,16 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"start": "node .output/server/index.mjs",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "4.6.1",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"nuxt": "^4.4.2",
"postgres": "^3.4.9",
"redis": "^5.11.0",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},

289
pnpm-lock.yaml generated
View File

@@ -11,9 +11,21 @@ importers:
'@nuxt/ui':
specifier: 4.6.1
version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)
'@simplewebauthn/browser':
specifier: ^13.3.0
version: 13.3.0
'@simplewebauthn/server':
specifier: ^13.3.0
version: 13.3.0
nuxt:
specifier: ^4.4.2
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4)(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(yaml@2.8.3)
postgres:
specifier: ^3.4.9
version: 3.4.9
redis:
specifier: ^5.11.0
version: 5.11.0
vue:
specifier: ^3.5.32
version: 3.5.32(typescript@6.0.2)
@@ -371,6 +383,9 @@ packages:
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@iconify/collections@1.0.671':
resolution: {integrity: sha512-+UO1BvRCf2zpuKg6REnJfgDjKvtMGjmTFKIZSRhOLK3A6sAFo1ZDB+AjDE1g0lzhKZ4KZsYM7b5tCHFFvalQQA==}
@@ -427,6 +442,9 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -1024,6 +1042,43 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@peculiar/asn1-android@2.6.0':
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
'@peculiar/asn1-cms@2.6.1':
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
'@peculiar/asn1-csr@2.6.1':
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
'@peculiar/asn1-ecc@2.6.1':
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
'@peculiar/asn1-pfx@2.6.1':
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
'@peculiar/asn1-pkcs8@2.6.1':
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
'@peculiar/asn1-pkcs9@2.6.1':
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
'@peculiar/asn1-rsa@2.6.1':
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
'@peculiar/asn1-schema@2.6.0':
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
'@peculiar/asn1-x509-attr@2.6.1':
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
'@peculiar/asn1-x509@2.6.1':
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
'@peculiar/x509@1.14.3':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1040,6 +1095,39 @@ packages:
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@redis/bloom@5.11.0':
resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.11.0
'@redis/client@5.11.0':
resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==}
engines: {node: '>= 18'}
peerDependencies:
'@node-rs/xxhash': ^1.1.0
peerDependenciesMeta:
'@node-rs/xxhash':
optional: true
'@redis/json@5.11.0':
resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.11.0
'@redis/search@5.11.0':
resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.11.0
'@redis/time-series@5.11.0':
resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.11.0
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@@ -1252,6 +1340,13 @@ packages:
'@simple-git/argv-parser@1.1.0':
resolution: {integrity: sha512-sUKOu2lb5vGIWADNNLpscyj07DAeQZU3KLbnE2Tj53tW6BbDQKMly2CCfnR4oYzqtRELCPWfwaPg+Q0T8qfKBg==}
'@simplewebauthn/browser@13.3.0':
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
'@simplewebauthn/server@13.3.0':
resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==}
engines: {node: '>=20.0.0'}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
@@ -1838,6 +1933,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
asn1js@3.0.7:
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
engines: {node: '>=12.0.0'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
@@ -3292,6 +3391,10 @@ packages:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
postgres@3.4.9:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -3369,6 +3472,13 @@ packages:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -3411,6 +3521,13 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redis@5.11.0:
resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==}
engines: {node: '>= 18'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
@@ -3708,9 +3825,16 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
type-fest@5.5.0:
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
engines: {node: '>=20'}
@@ -4475,6 +4599,8 @@ snapshots:
- '@vue/composition-api'
- vue
'@hexagon/base64@1.1.28': {}
'@iconify/collections@1.0.671':
dependencies:
'@iconify/types': 2.0.0
@@ -4547,6 +4673,8 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
'@levischuck/tiny-cbor@0.2.11': {}
'@mapbox/node-pre-gyp@2.0.3':
dependencies:
consola: 3.4.2
@@ -5321,6 +5449,102 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@peculiar/asn1-android@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-cms@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-csr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-ecc@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pfx@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pfx': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-rsa@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-schema@2.6.0':
dependencies:
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-x509@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.3':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-csr': 2.6.1
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-pkcs9': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -5338,6 +5562,26 @@ snapshots:
'@poppinss/exception@1.2.3': {}
'@redis/bloom@5.11.0(@redis/client@5.11.0)':
dependencies:
'@redis/client': 5.11.0
'@redis/client@5.11.0':
dependencies:
cluster-key-slot: 1.1.2
'@redis/json@5.11.0(@redis/client@5.11.0)':
dependencies:
'@redis/client': 5.11.0
'@redis/search@5.11.0(@redis/client@5.11.0)':
dependencies:
'@redis/client': 5.11.0
'@redis/time-series@5.11.0(@redis/client@5.11.0)':
dependencies:
'@redis/client': 5.11.0
'@remirror/core-constants@3.0.0': {}
'@rolldown/pluginutils@1.0.0-rc.15': {}
@@ -5488,6 +5732,19 @@ snapshots:
dependencies:
'@simple-git/args-pathspec': 1.0.3
'@simplewebauthn/browser@13.3.0': {}
'@simplewebauthn/server@13.3.0':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.6.0
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/x509': 1.14.3
'@sindresorhus/is@7.2.0': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -6098,6 +6355,12 @@ snapshots:
dependencies:
tslib: 2.8.1
asn1js@3.0.7:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.2
@@ -7748,6 +8011,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres@3.4.9: {}
powershell-utils@0.1.0: {}
pretty-bytes@7.1.0: {}
@@ -7861,6 +8126,12 @@ snapshots:
punycode.js@2.3.1: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
@@ -7906,6 +8177,18 @@ snapshots:
dependencies:
redis-errors: 1.2.0
redis@5.11.0:
dependencies:
'@redis/bloom': 5.11.0(@redis/client@5.11.0)
'@redis/client': 5.11.0
'@redis/json': 5.11.0(@redis/client@5.11.0)
'@redis/search': 5.11.0(@redis/client@5.11.0)
'@redis/time-series': 5.11.0(@redis/client@5.11.0)
transitivePeerDependencies:
- '@node-rs/xxhash'
reflect-metadata@0.2.2: {}
regexp-tree@0.1.27: {}
reka-ui@2.9.3(vue@3.5.32(typescript@6.0.2)):
@@ -8232,8 +8515,14 @@ snapshots:
tr46@0.0.3: {}
tslib@1.14.1: {}
tslib@2.8.1: {}
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
type-fest@5.5.0:
dependencies:
tagged-tag: 1.0.0

View File

@@ -0,0 +1,10 @@
import { requireRole } from '../../utils/auth'
import { listUsers } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
await requireRole(event, 'super_admin')
return {
users: await listUsers()
}
})

View File

@@ -0,0 +1,74 @@
import {
DEFAULT_USER_PASSWORD,
USERNAME_PATTERN,
isValidPhoneNumber,
normalizePhoneNumber,
type UserRole
} from '~~/shared/auth'
import { normalizeUsername, requireRole } from '../../utils/auth'
import { hashPassword } from '../../utils/password'
import { createUser } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireRole(event, 'super_admin')
const body = await readBody<{
username?: string
fullName?: string
phoneNumber?: string
role?: UserRole
}>(event)
const username = normalizeUsername(body.username || '')
const fullName = body.fullName?.trim() || ''
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
const role = body.role === 'super_admin' ? 'super_admin' : 'staff'
if (!USERNAME_PATTERN.test(username)) {
throw createError({
statusCode: 400,
statusMessage: 'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore'
})
}
if (fullName.length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Full name must be at least 2 characters'
})
}
if (!isValidPhoneNumber(phoneNumber)) {
throw createError({
statusCode: 400,
statusMessage: 'Phone number must contain 8 to 15 digits'
})
}
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
try {
const user = await createUser({
username,
fullName,
phoneNumber,
role,
passwordHash,
createdBy: auth.user.id
})
return {
user,
defaultPassword: DEFAULT_USER_PASSWORD
}
} catch (error: any) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
statusMessage: 'Username already exists'
})
}
throw error
}
})

View File

@@ -0,0 +1,78 @@
import {
isValidPhoneNumber,
normalizePhoneNumber,
type UserRole
} from '~~/shared/auth'
import { requireRole } from '../../../utils/auth'
import { getUserById, updateUserProfile } from '../../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireRole(event, 'super_admin')
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: 'User id is required'
})
}
const body = await readBody<{
fullName?: string
phoneNumber?: string
role?: UserRole
}>(event)
const fullName = body.fullName?.trim() || ''
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
const role = body.role
if (fullName.length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Display name must be at least 2 characters'
})
}
if (!isValidPhoneNumber(phoneNumber)) {
throw createError({
statusCode: 400,
statusMessage: 'Phone number must contain 8 to 15 digits'
})
}
if (role !== 'super_admin' && role !== 'staff') {
throw createError({
statusCode: 400,
statusMessage: 'Role is invalid'
})
}
const user = await getUserById(userId)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
})
}
if (auth.user.id === userId && role !== 'super_admin') {
throw createError({
statusCode: 400,
statusMessage: 'You cannot remove your own super admin access'
})
}
const updatedUser = await updateUserProfile({
userId,
fullName,
phoneNumber,
role
})
return {
user: updatedUser
}
})

View File

@@ -0,0 +1,42 @@
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
import { requireRole } from '../../../../utils/auth'
import { hashPassword } from '../../../../utils/password'
import { getUserById, updateUserPassword } from '../../../../utils/user-repository'
export default defineEventHandler(async (event) => {
await requireRole(event, 'super_admin')
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: 'User id is required'
})
}
const user = await getUserById(userId)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
})
}
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
await updateUserPassword({
userId,
passwordHash,
mustChangePassword: true
})
const updatedUser = await getUserById(userId)
return {
user: updatedUser,
defaultPassword: DEFAULT_USER_PASSWORD
}
})

View File

@@ -0,0 +1,60 @@
import { MIN_PASSWORD_LENGTH } from '~~/shared/auth'
import { requireAuth } from '../../utils/auth'
import { hashPassword, verifyPassword } from '../../utils/password'
import { getUserById, updateUserPassword } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const body = await readBody<{
currentPassword?: string
newPassword?: string
}>(event)
const currentPassword = body.currentPassword?.trim() || ''
const newPassword = body.newPassword?.trim() || ''
if (!currentPassword || !newPassword) {
throw createError({
statusCode: 400,
statusMessage: 'Current password and new password are required'
})
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw createError({
statusCode: 400,
statusMessage: `New password must be at least ${MIN_PASSWORD_LENGTH} characters`
})
}
if (currentPassword === newPassword) {
throw createError({
statusCode: 400,
statusMessage: 'New password must be different from the current password'
})
}
const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash)
if (!currentPasswordMatches) {
throw createError({
statusCode: 400,
statusMessage: 'Current password is incorrect'
})
}
const passwordHash = await hashPassword(newPassword)
await updateUserPassword({
userId: auth.user.id,
passwordHash,
mustChangePassword: false
})
const updatedUser = await getUserById(auth.user.id)
return {
user: updatedUser
}
})

View File

@@ -0,0 +1,46 @@
import { verifyPassword } from '../../utils/password'
import { normalizeUsername, signInUser } from '../../utils/auth'
import { getUserByUsername } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const body = await readBody<{
username?: string
password?: string
remember?: boolean
}>(event)
const username = normalizeUsername(body.username || '')
const password = body.password?.trim() || ''
const remember = body.remember !== false
if (!username || !password) {
throw createError({
statusCode: 400,
statusMessage: 'Username and password are required'
})
}
const user = await getUserByUsername(username)
if (!user || !user.isActive) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password'
})
}
const passwordMatches = await verifyPassword(password, user.passwordHash)
if (!passwordMatches) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password'
})
}
const authenticatedUser = await signInUser(event, user, remember)
return {
user: authenticatedUser
}
})

View File

@@ -0,0 +1,9 @@
import { destroyUserSession } from '../../utils/session'
export default defineEventHandler(async (event) => {
await destroyUserSession(event)
return {
ok: true
}
})

View File

@@ -0,0 +1,9 @@
import { getAuthContext } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event)
return {
user: auth?.user ?? null
}
})

View File

@@ -0,0 +1,17 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { createLoginChallenge, getWebAuthnConfig } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const config = getWebAuthnConfig(event)
const options = await generateAuthenticationOptions({
rpID: config.rpID,
userVerification: 'preferred'
})
const challengeToken = await createLoginChallenge(options.challenge)
return {
options,
challengeToken
}
})

View File

@@ -0,0 +1,80 @@
import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server'
import { getUserById, getCredentialForVerification, updateCredentialCounter } from '../../../../utils/user-repository'
import { consumeLoginChallenge, getWebAuthnConfig, toWebAuthnCredential } from '../../../../utils/webauthn'
import { signInUser } from '../../../../utils/auth'
export default defineEventHandler(async (event) => {
const body = await readBody<{
response?: AuthenticationResponseJSON
challengeToken?: string
remember?: boolean
}>(event)
const response = body.response
const challengeToken = body.challengeToken?.trim() || ''
const remember = body.remember !== false
if (!response || !challengeToken) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey login payload is incomplete'
})
}
const expectedChallenge = await consumeLoginChallenge(challengeToken)
if (!expectedChallenge) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey login challenge expired. Try again.'
})
}
const storedCredential = await getCredentialForVerification(response.id)
if (!storedCredential) {
throw createError({
statusCode: 401,
statusMessage: 'Passkey is not recognized'
})
}
const config = getWebAuthnConfig(event)
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: toWebAuthnCredential(storedCredential)
})
if (!verification.verified) {
throw createError({
statusCode: 401,
statusMessage: 'Passkey authentication failed'
})
}
const user = await getUserById(storedCredential.userId)
if (!user || !user.isActive) {
throw createError({
statusCode: 401,
statusMessage: 'User account is not available'
})
}
await updateCredentialCounter({
credentialId: storedCredential.credentialId,
counter: verification.authenticationInfo.newCounter,
deviceType: verification.authenticationInfo.credentialDeviceType,
backedUp: verification.authenticationInfo.credentialBackedUp
})
const authenticatedUser = await signInUser(event, user, remember)
return {
user: authenticatedUser
}
})

View File

@@ -0,0 +1,30 @@
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { requireAuth } from '../../../../utils/auth'
import { listCredentialDescriptors } from '../../../../utils/user-repository'
import { getWebAuthnConfig, storeRegistrationChallenge } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const config = getWebAuthnConfig(event)
const excludeCredentials = await listCredentialDescriptors(auth.user.id)
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpID,
userName: auth.user.username,
userDisplayName: auth.user.fullName,
userID: Buffer.from(auth.user.id),
excludeCredentials,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
preferredAuthenticatorType: 'localDevice'
})
await storeRegistrationChallenge(auth.user.id, options.challenge)
return {
options
}
})

View File

@@ -0,0 +1,74 @@
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server'
import { requireAuth } from '../../../../utils/auth'
import { createUserPasskey, getUserById, listUserPasskeys } from '../../../../utils/user-repository'
import { buildPasskeyLabel, consumeRegistrationChallenge, getWebAuthnConfig } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const body = await readBody<{
response?: RegistrationResponseJSON
}>(event)
if (!body.response) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration response is required'
})
}
const expectedChallenge = await consumeRegistrationChallenge(auth.user.id)
if (!expectedChallenge) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration challenge expired. Try again.'
})
}
const config = getWebAuthnConfig(event)
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID
})
if (!verification.verified || !verification.registrationInfo) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration could not be verified'
})
}
try {
await createUserPasskey({
userId: auth.user.id,
credentialId: verification.registrationInfo.credential.id,
publicKey: verification.registrationInfo.credential.publicKey,
counter: verification.registrationInfo.credential.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
transports: body.response.response.transports || [],
label: buildPasskeyLabel()
})
} catch (error: any) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
statusMessage: 'This passkey is already registered'
})
}
throw error
}
const updatedUser = await getUserById(auth.user.id)
const passkeys = await listUserPasskeys(auth.user.id)
return {
ok: true,
user: updatedUser,
passkeys
}
})

View File

@@ -0,0 +1,11 @@
import { requireAuth } from '../../utils/auth'
import { listUserPasskeys } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const passkeys = await listUserPasskeys(auth.user.id)
return {
passkeys
}
})

23
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ensureDatabaseReady } from '../utils/db-init'
import { getRedisClient } from '../utils/redis'
import { getSqlClient } from '../utils/postgres'
export default defineEventHandler(async () => {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`select 1`
const redis = await getRedisClient()
await redis.ping()
return {
ok: true,
services: {
app: 'up',
postgres: 'up',
redis: 'up'
},
timestamp: new Date().toISOString()
}
})

View File

@@ -0,0 +1,7 @@
import { listPublicContacts } from '../../utils/user-repository'
export default defineEventHandler(async () => {
return {
contacts: await listPublicContacts()
}
})

View File

@@ -0,0 +1,14 @@
import { ensureDatabaseReady } from '../utils/db-init'
import { withRetry } from '../utils/retry'
export default defineNitroPlugin(async () => {
await withRetry(
() => ensureDatabaseReady(),
{
label: 'database bootstrap',
retries: 15,
delayMs: 1_000,
factor: 1.4
}
)
})

78
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { H3Event } from 'h3'
import type { UserRole } from '~~/shared/auth'
import { createUserSession, destroyUserSession, getUserSession } from './session'
import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository'
export function normalizeUsername(value: string) {
return value.trim().toLowerCase()
}
export async function getAuthContext(event: H3Event): Promise<{
session: Awaited<ReturnType<typeof getUserSession>>
user: UserAuthRecord
} | null> {
const session = await getUserSession(event)
if (!session) {
return null
}
const user = await getUserById(session.userId)
if (!user || !user.isActive) {
await destroyUserSession(event)
return null
}
return {
session,
user
}
}
export async function requireAuth(event: H3Event) {
const auth = await getAuthContext(event)
if (!auth) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
return auth
}
export async function requireRole(event: H3Event, role: UserRole) {
const auth = await requireAuth(event)
if (auth.user.role !== role) {
throw createError({
statusCode: 403,
statusMessage: 'You are not allowed to perform this action'
})
}
return auth
}
export async function signInUser(event: H3Event, user: UserAuthRecord, remember: boolean) {
await createUserSession(event, {
userId: user.id,
remember
})
await updateLastLogin(user.id)
const refreshedUser = await getUserById(user.id)
if (!refreshedUser) {
throw createError({
statusCode: 500,
statusMessage: 'Unable to load authenticated user'
})
}
return refreshedUser
}

41
server/utils/base64url.ts Normal file
View File

@@ -0,0 +1,41 @@
import { randomBytes } from 'node:crypto'
export function encodeBase64Url(value: Buffer | Uint8Array | string): string {
const buffer = typeof value === 'string'
? Buffer.from(value, 'utf8')
: Buffer.from(value)
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
export function decodeBase64Url(value: string): Buffer {
const normalized = value
.replace(/-/g, '+')
.replace(/_/g, '/')
const padding = normalized.length % 4 === 0
? ''
: '='.repeat(4 - (normalized.length % 4))
return Buffer.from(`${normalized}${padding}`, 'base64')
}
export function randomToken(length = 32): string {
return encodeBase64Url(randomBytes(length))
}
export function toIsoString(value: Date | string | null): string | null {
if (!value) {
return null
}
if (value instanceof Date) {
return value.toISOString()
}
return new Date(value).toISOString()
}

106
server/utils/db-init.ts Normal file
View File

@@ -0,0 +1,106 @@
import { randomUUID } from 'node:crypto'
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
import { hashPassword } from './password'
import { getSqlClient } from './postgres'
let databaseReadyPromise: Promise<void> | null = null
export async function ensureDatabaseReady() {
if (!databaseReadyPromise) {
databaseReadyPromise = initializeDatabase()
}
return databaseReadyPromise
}
async function initializeDatabase() {
const sql = getSqlClient()
await sql`
create table if not exists users (
id text primary key,
username text not null unique,
full_name text not null,
phone_number text,
role text not null check (role in ('super_admin', 'staff')),
password_hash text not null,
must_change_password boolean not null default true,
is_active boolean not null default true,
created_by text references users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_login_at timestamptz
)
`
await sql`
alter table users
add column if not exists phone_number text
`
await sql`
create table if not exists user_passkeys (
id text primary key,
user_id text not null references users(id) on delete cascade,
credential_id text not null unique,
public_key text not null,
counter bigint not null default 0,
device_type text not null check (device_type in ('singleDevice', 'multiDevice')),
backed_up boolean not null default false,
transports jsonb not null default '[]'::jsonb,
label text not null,
created_at timestamptz not null default now(),
last_used_at timestamptz
)
`
await sql`
create index if not exists user_passkeys_user_id_idx
on user_passkeys (user_id)
`
const [existingSuperAdmin] = await sql<{ id: string }[]>`
select id
from users
where username = 'xiaomai'
limit 1
`
if (!existingSuperAdmin) {
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
await sql`
insert into users (
id,
username,
full_name,
role,
password_hash,
must_change_password,
is_active,
created_by
)
values (
${randomUUID()},
'xiaomai',
'Xiaomai',
'super_admin',
${passwordHash},
true,
true,
null
)
`
}
await sql`
update users
set
phone_number = '601157753558',
updated_at = now()
where username = 'xiaomai'
and (phone_number is null or phone_number = '')
`
}

52
server/utils/password.ts Normal file
View File

@@ -0,0 +1,52 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import { decodeBase64Url, encodeBase64Url } from './base64url'
const scrypt = promisify(scryptCallback)
const SCRYPT_COST = 16_384
const SCRYPT_BLOCK_SIZE = 8
const SCRYPT_PARALLELIZATION = 1
const KEY_LENGTH = 64
export async function hashPassword(password: string): Promise<string> {
const salt = encodeBase64Url(randomBytes(16))
const derivedKey = await scrypt(password, salt, KEY_LENGTH, {
N: SCRYPT_COST,
r: SCRYPT_BLOCK_SIZE,
p: SCRYPT_PARALLELIZATION
}) as Buffer
return [
'scrypt',
SCRYPT_COST,
SCRYPT_BLOCK_SIZE,
SCRYPT_PARALLELIZATION,
salt,
encodeBase64Url(derivedKey)
].join('$')
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [algorithm, cost, blockSize, parallelization, salt, key] = storedHash.split('$')
if (
algorithm !== 'scrypt'
|| !cost
|| !blockSize
|| !parallelization
|| !salt
|| !key
) {
return false
}
const expectedKey = decodeBase64Url(key)
const derivedKey = await scrypt(password, salt, expectedKey.length, {
N: Number(cost),
r: Number(blockSize),
p: Number(parallelization)
}) as Buffer
return timingSafeEqual(expectedKey, derivedKey)
}

18
server/utils/postgres.ts Normal file
View File

@@ -0,0 +1,18 @@
import postgres from 'postgres'
let sqlClient: postgres.Sql | null = null
export function getSqlClient() {
if (!sqlClient) {
const config = useRuntimeConfig()
sqlClient = postgres(
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
{
max: 10
}
)
}
return sqlClient
}

20
server/utils/redis.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createClient, type RedisClientType } from 'redis'
let redisClientPromise: Promise<RedisClientType> | null = null
export async function getRedisClient() {
if (!redisClientPromise) {
const config = useRuntimeConfig()
const client = createClient({
url: config.redisUrl || 'redis://127.0.0.1:6379'
})
client.on('error', (error) => {
console.error('Redis connection error', error)
})
redisClientPromise = client.connect().then(() => client)
}
return redisClientPromise
}

39
server/utils/retry.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface RetryOptions {
retries?: number
delayMs?: number
factor?: number
label?: string
}
function sleep(delayMs: number) {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
export async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions = {}
) {
const retries = options.retries ?? 10
const delayMs = options.delayMs ?? 1_000
const factor = options.factor ?? 1.5
const label = options.label ?? 'operation'
let attempt = 0
let currentDelay = delayMs
while (true) {
try {
return await operation()
} catch (error) {
attempt += 1
if (attempt > retries) {
throw error
}
console.warn(`${label} failed on attempt ${attempt}. Retrying in ${currentDelay}ms.`)
await sleep(currentDelay)
currentDelay = Math.round(currentDelay * factor)
}
}
}

103
server/utils/session.ts Normal file
View File

@@ -0,0 +1,103 @@
import { randomUUID } from 'node:crypto'
import type { H3Event } from 'h3'
import { deleteCookie, getCookie, getRequestURL, setCookie } from 'h3'
import { randomToken } from './base64url'
import { getRedisClient } from './redis'
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
const SHORT_SESSION_TTL_SECONDS = 60 * 60 * 24
interface StoredSession {
id: string
userId: string
remember: boolean
createdAt: string
}
function getSessionCookieName() {
const config = useRuntimeConfig()
return config.sessionCookieName || 'dinner_ticket_session'
}
function getSessionStorageKey(token: string) {
return `auth:session:${token}`
}
function shouldUseSecureCookies(event: H3Event) {
const url = getRequestURL(event)
return url.protocol === 'https:'
}
function getSessionTtl(remember: boolean) {
return remember ? DEFAULT_SESSION_TTL_SECONDS : SHORT_SESSION_TTL_SECONDS
}
export async function createUserSession(event: H3Event, input: {
userId: string
remember: boolean
}) {
const token = randomToken(32)
const session: StoredSession = {
id: randomUUID(),
userId: input.userId,
remember: input.remember,
createdAt: new Date().toISOString()
}
const redis = await getRedisClient()
const ttl = getSessionTtl(input.remember)
await redis.set(getSessionStorageKey(token), JSON.stringify(session), {
expiration: {
type: 'EX',
value: ttl
}
})
setCookie(event, getSessionCookieName(), token, {
httpOnly: true,
sameSite: 'lax',
secure: shouldUseSecureCookies(event),
path: '/',
maxAge: ttl
})
return session
}
export async function getUserSession(event: H3Event): Promise<StoredSession | null> {
const token = getCookie(event, getSessionCookieName())
if (!token) {
return null
}
const redis = await getRedisClient()
const raw = await redis.get(getSessionStorageKey(token))
if (!raw) {
deleteCookie(event, getSessionCookieName(), { path: '/' })
return null
}
try {
return JSON.parse(raw) as StoredSession
} catch {
await redis.del(getSessionStorageKey(token))
deleteCookie(event, getSessionCookieName(), { path: '/' })
return null
}
}
export async function destroyUserSession(event: H3Event) {
const token = getCookie(event, getSessionCookieName())
if (token) {
const redis = await getRedisClient()
await redis.del(getSessionStorageKey(token))
}
deleteCookie(event, getSessionCookieName(), { path: '/' })
}

View File

@@ -0,0 +1,492 @@
import { randomUUID } from 'node:crypto'
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
import { encodeBase64Url, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
import { getSqlClient } from './postgres'
type DbUserRow = {
id: string
username: string
full_name: string
phone_number: string | null
role: UserRole
password_hash: string
must_change_password: boolean
is_active: boolean
created_by: string | null
created_at: Date | string
last_login_at: Date | string | null
passkey_count: number | string
}
type DbPasskeyRow = {
id: string
user_id: string
credential_id: string
public_key: string
counter: number | string
device_type: CredentialDeviceType
backed_up: boolean
transports: AuthenticatorTransportFuture[] | string
label: string
created_at: Date | string
last_used_at: Date | string | null
}
export interface UserAuthRecord extends AuthUser {
passwordHash: string
createdBy: string | null
}
export interface PasskeyRecord {
id: string
userId: string
credentialId: string
publicKey: string
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
transports: AuthenticatorTransportFuture[]
label: string
createdAt: string
lastUsedAt: string | null
}
function parseCount(value: number | string): number {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function parseCounter(value: number | string): number {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] {
if (Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function mapAuthUser(row: DbUserRow): AuthUser {
const passkeyCount = parseCount(row.passkey_count)
return {
id: row.id,
username: row.username,
fullName: row.full_name,
phoneNumber: row.phone_number,
role: row.role,
isActive: row.is_active,
mustChangePassword: row.must_change_password,
needsPasskeySetup: passkeyCount === 0,
passkeyCount,
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
lastLoginAt: toIsoString(row.last_login_at)
}
}
function mapManagedUser(row: DbUserRow): ManagedUser {
return {
...mapAuthUser(row),
createdBy: row.created_by
}
}
function mapUserAuthRecord(row: DbUserRow): UserAuthRecord {
return {
...mapAuthUser(row),
passwordHash: row.password_hash,
createdBy: row.created_by
}
}
function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord {
return {
id: row.id,
userId: row.user_id,
credentialId: row.credential_id,
publicKey: row.public_key,
counter: parseCounter(row.counter),
deviceType: row.device_type,
backedUp: row.backed_up,
transports: parseTransports(row.transports),
label: row.label,
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
lastUsedAt: toIsoString(row.last_used_at)
}
}
function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary {
const record = mapPasskeyRecord(row)
return {
id: record.id,
label: record.label,
createdAt: record.createdAt,
lastUsedAt: record.lastUsedAt,
deviceType: record.deviceType,
backedUp: record.backedUp
}
}
export async function getUserById(userId: string): Promise<UserAuthRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
where users.id = ${userId}
limit 1
`
return row ? mapUserAuthRecord(row) : null
}
export async function getUserByUsername(username: string): Promise<UserAuthRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
where users.username = ${username}
limit 1
`
return row ? mapUserAuthRecord(row) : null
}
export async function listUsers(): Promise<ManagedUser[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.created_at asc
`
return rows.map(mapManagedUser)
}
export async function listPublicContacts(): Promise<PublicContact[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
from users
where users.is_active = true
and users.phone_number is not null
and users.phone_number <> ''
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.full_name asc
`
return rows.map((row) => ({
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
role: row.role
}))
}
export async function createUser(input: {
username: string
fullName: string
phoneNumber: string
role: UserRole
passwordHash: string
createdBy: string
}): Promise<UserAuthRecord> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
insert into users (
id,
username,
full_name,
phone_number,
role,
password_hash,
must_change_password,
is_active,
created_by
)
values (
${randomUUID()},
${input.username},
${input.fullName},
${input.phoneNumber},
${input.role},
${input.passwordHash},
true,
true,
${input.createdBy}
)
returning
id,
username,
full_name,
phone_number,
role,
password_hash,
must_change_password,
is_active,
created_by,
created_at,
last_login_at,
0::int as passkey_count
`
return mapUserAuthRecord(row)
}
export async function updateUserProfile(input: {
userId: string
fullName: string
phoneNumber: string
role: UserRole
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
full_name = ${input.fullName},
phone_number = ${input.phoneNumber},
role = ${input.role},
updated_at = now()
where id = ${input.userId}
`
return getUserById(input.userId)
}
export async function updateUserPassword(input: {
userId: string
passwordHash: string
mustChangePassword: boolean
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
password_hash = ${input.passwordHash},
must_change_password = ${input.mustChangePassword},
updated_at = now()
where id = ${input.userId}
`
}
export async function updateLastLogin(userId: string) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
last_login_at = now(),
updated_at = now()
where id = ${userId}
`
}
export async function listUserPasskeys(userId: string): Promise<PasskeySummary[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<DbPasskeyRow[]>`
select
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label,
created_at,
last_used_at
from user_passkeys
where user_id = ${userId}
order by created_at asc
`
return rows.map(mapPasskeySummary)
}
export async function getCredentialForVerification(credentialId: string): Promise<PasskeyRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbPasskeyRow[]>`
select
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label,
created_at,
last_used_at
from user_passkeys
where credential_id = ${credentialId}
limit 1
`
return row ? mapPasskeyRecord(row) : null
}
export async function listCredentialDescriptors(userId: string) {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<Pick<DbPasskeyRow, 'credential_id' | 'transports'>[]>`
select credential_id, transports
from user_passkeys
where user_id = ${userId}
order by created_at asc
`
return rows.map((row) => ({
id: row.credential_id,
transports: parseTransports(row.transports)
}))
}
export async function createUserPasskey(input: {
userId: string
credentialId: string
publicKey: Uint8Array
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
transports: AuthenticatorTransportFuture[]
label: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
insert into user_passkeys (
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label
)
values (
${randomUUID()},
${input.userId},
${input.credentialId},
${encodeBase64Url(input.publicKey)},
${input.counter},
${input.deviceType},
${input.backedUp},
${sql.json(input.transports)},
${input.label}
)
`
}
export async function updateCredentialCounter(input: {
credentialId: string
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update user_passkeys
set
counter = ${input.counter},
device_type = ${input.deviceType},
backed_up = ${input.backedUp},
last_used_at = now()
where credential_id = ${input.credentialId}
`
}

104
server/utils/webauthn.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { H3Event } from 'h3'
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
import { getRequestURL } from 'h3'
import { decodeBase64Url, randomToken } from './base64url'
import { getRedisClient } from './redis'
import type { PasskeyRecord } from './user-repository'
const CHALLENGE_TTL_SECONDS = 60 * 5
function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function getWebAuthnConfig(event: H3Event) {
const config = useRuntimeConfig()
const origin = getAppOrigin(event)
const originUrl = new URL(origin)
return {
origin,
rpID: originUrl.hostname,
rpName: config.public.rpName || 'Dinner Ticket System'
}
}
function getRegistrationChallengeKey(userId: string) {
return `webauthn:register:${userId}`
}
function getLoginChallengeKey(token: string) {
return `webauthn:login:${token}`
}
export async function storeRegistrationChallenge(userId: string, challenge: string) {
const redis = await getRedisClient()
await redis.set(getRegistrationChallengeKey(userId), challenge, {
expiration: {
type: 'EX',
value: CHALLENGE_TTL_SECONDS
}
})
}
export async function consumeRegistrationChallenge(userId: string) {
const redis = await getRedisClient()
const key = getRegistrationChallengeKey(userId)
const challenge = await redis.get(key)
if (challenge) {
await redis.del(key)
}
return challenge
}
export async function createLoginChallenge(challenge: string) {
const token = randomToken(24)
const redis = await getRedisClient()
await redis.set(getLoginChallengeKey(token), challenge, {
expiration: {
type: 'EX',
value: CHALLENGE_TTL_SECONDS
}
})
return token
}
export async function consumeLoginChallenge(token: string) {
const redis = await getRedisClient()
const key = getLoginChallengeKey(token)
const challenge = await redis.get(key)
if (challenge) {
await redis.del(key)
}
return challenge
}
export function toWebAuthnCredential(passkey: PasskeyRecord): WebAuthnCredential {
return {
id: passkey.credentialId,
publicKey: decodeBase64Url(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[]
}
}
export function buildPasskeyLabel() {
return `Passkey ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`
}

57
shared/auth.ts Normal file
View File

@@ -0,0 +1,57 @@
export const DEFAULT_USER_PASSWORD = '123456'
export const MIN_PASSWORD_LENGTH = 8
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
export type UserRole = 'super_admin' | 'staff'
export function normalizePhoneNumber(value: string) {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
const hasPlusPrefix = trimmed.startsWith('+')
const digitsOnly = trimmed.replace(/\D/g, '')
return hasPlusPrefix ? `+${digitsOnly}` : digitsOnly
}
export function isValidPhoneNumber(value: string) {
return PHONE_NUMBER_PATTERN.test(normalizePhoneNumber(value))
}
export interface AuthUser {
id: string
username: string
fullName: string
phoneNumber: string | null
role: UserRole
isActive: boolean
mustChangePassword: boolean
needsPasskeySetup: boolean
passkeyCount: number
createdAt: string
lastLoginAt: string | null
}
export interface ManagedUser extends AuthUser {
createdBy: string | null
}
export interface PublicContact {
id: string
fullName: string
phoneNumber: string
role: UserRole
}
export interface PasskeySummary {
id: string
label: string
createdAt: string
lastUsedAt: string | null
deviceType: 'singleDevice' | 'multiDevice'
backedUp: boolean
}