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:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
9
.env.example
Normal 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
28
Dockerfile
Normal 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
144
README.md
@@ -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
|
||||
```
|
||||
|
||||
10
app/composables/useApiClient.ts
Normal file
10
app/composables/useApiClient.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
54
app/composables/useAuth.ts
Normal file
54
app/composables/useAuth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
12
app/middleware/auth.ts
Normal 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
14
app/middleware/guest.ts
Normal 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')
|
||||
})
|
||||
16
app/middleware/super-admin.ts
Normal file
16
app/middleware/super-admin.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
469
app/pages/management/users/index.vue
Normal file
469
app/pages/management/users/index.vue
Normal 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>
|
||||
268
app/pages/security/index.vue
Normal file
268
app/pages/security/index.vue
Normal 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
64
docker-compose.yml
Normal 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:
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -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
289
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
10
server/api/admin/users.get.ts
Normal file
10
server/api/admin/users.get.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
74
server/api/admin/users.post.ts
Normal file
74
server/api/admin/users.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
78
server/api/admin/users/[id].patch.ts
Normal file
78
server/api/admin/users/[id].patch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
42
server/api/admin/users/[id]/reset-password.post.ts
Normal file
42
server/api/admin/users/[id]/reset-password.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
60
server/api/auth/change-password.post.ts
Normal file
60
server/api/auth/change-password.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
46
server/api/auth/login.post.ts
Normal file
46
server/api/auth/login.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
9
server/api/auth/logout.post.ts
Normal file
9
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { destroyUserSession } from '../../utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await destroyUserSession(event)
|
||||
|
||||
return {
|
||||
ok: true
|
||||
}
|
||||
})
|
||||
9
server/api/auth/me.get.ts
Normal file
9
server/api/auth/me.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
17
server/api/auth/passkey/login/options.post.ts
Normal file
17
server/api/auth/passkey/login/options.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
80
server/api/auth/passkey/login/verify.post.ts
Normal file
80
server/api/auth/passkey/login/verify.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
30
server/api/auth/passkey/register/options.post.ts
Normal file
30
server/api/auth/passkey/register/options.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
74
server/api/auth/passkey/register/verify.post.ts
Normal file
74
server/api/auth/passkey/register/verify.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
11
server/api/auth/passkeys.get.ts
Normal file
11
server/api/auth/passkeys.get.ts
Normal 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
23
server/api/health.get.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
7
server/api/public/contacts.get.ts
Normal file
7
server/api/public/contacts.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { listPublicContacts } from '../../utils/user-repository'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return {
|
||||
contacts: await listPublicContacts()
|
||||
}
|
||||
})
|
||||
14
server/plugins/bootstrap.ts
Normal file
14
server/plugins/bootstrap.ts
Normal 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
78
server/utils/auth.ts
Normal 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
41
server/utils/base64url.ts
Normal 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
106
server/utils/db-init.ts
Normal 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
52
server/utils/password.ts
Normal 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
18
server/utils/postgres.ts
Normal 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
20
server/utils/redis.ts
Normal 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
39
server/utils/retry.ts
Normal 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
103
server/utils/session.ts
Normal 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: '/' })
|
||||
}
|
||||
492
server/utils/user-repository.ts
Normal file
492
server/utils/user-repository.ts
Normal 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
104
server/utils/webauthn.ts
Normal 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
57
shared/auth.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user