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
|
## Setup
|
||||||
|
|
||||||
Make sure to install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Server
|
## Development
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
Start the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm dev
|
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
|
## Production
|
||||||
|
|
||||||
Build the application for production:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Locally preview production build:
|
Preview the built server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
node .output/server/index.mjs
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn preview
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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,13 +1,282 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-dvh bg-default text-default">
|
<div class="relative min-h-dvh bg-default text-default">
|
||||||
<header class="border-b border-default bg-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" />
|
||||||
<UContainer class="flex items-center justify-between gap-4 py-6">
|
|
||||||
<UBadge
|
<template v-if="auth.user.value">
|
||||||
label="Event Ticket System"
|
<Transition
|
||||||
color="neutral"
|
enter-active-class="transition-opacity duration-200 ease-out"
|
||||||
variant="soft"
|
enter-from-class="opacity-0"
|
||||||
class="rounded-full px-3 py-1 font-semibold"
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="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
|
<UButton
|
||||||
id="loginBtn"
|
id="loginBtn"
|
||||||
@@ -16,22 +285,132 @@
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||||
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||||
|
class="rounded-full"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<UMain>
|
<UMain class="relative z-10">
|
||||||
<slot />
|
<slot />
|
||||||
</UMain>
|
</UMain>
|
||||||
|
|
||||||
<footer class="border-t border-default bg-default">
|
<footer class="border-t border-default bg-default/96">
|
||||||
<UContainer class="py-5 text-center text-sm text-muted">
|
<UContainer class="py-5 text-center text-sm text-muted">
|
||||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</footer>
|
</footer>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
interface SystemMenuItem {
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
icon: string
|
||||||
|
requiresSuperAdmin?: boolean
|
||||||
|
matches: (path: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
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>
|
</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>
|
<script lang="ts" setup>
|
||||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
|
||||||
|
|
||||||
type BookingMode = 'table' | 'pax'
|
type BookingMode = 'table' | 'pax'
|
||||||
type TicketType = 'vip' | 'supporter'
|
type TicketType = 'vip' | 'supporter'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const apiClient = useApiClient()
|
||||||
|
|
||||||
const eventDetails = [
|
const eventDetails = [
|
||||||
{
|
{
|
||||||
@@ -50,16 +53,13 @@ const ticketCatalog = [
|
|||||||
}
|
}
|
||||||
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
||||||
|
|
||||||
const personInCharge = [
|
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||||
{
|
const personInCharge = computed(() => {
|
||||||
label: 'Xiaomai',
|
return contactsResponse.contacts.map((contact) => ({
|
||||||
value: '601157753558'
|
label: contact.fullName,
|
||||||
},
|
value: contact.id
|
||||||
{
|
}))
|
||||||
label: 'Lily',
|
})
|
||||||
value: '60172661198'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat('en-MY', {
|
const priceFormatter = new Intl.NumberFormat('en-MY', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -76,7 +76,11 @@ const form = reactive({
|
|||||||
ticketType: 'vip' as TicketType
|
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(() => {
|
const selectedTicket = computed(() => {
|
||||||
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
|
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
|
||||||
@@ -101,7 +105,7 @@ function validateBooking(state: typeof form): FormError[] {
|
|||||||
|
|
||||||
if (!state.phone.trim()) {
|
if (!state.phone.trim()) {
|
||||||
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
|
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.' })
|
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>) {
|
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 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')
|
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
|
||||||
if (!bookingWindow) {
|
if (!bookingWindow) {
|
||||||
@@ -146,7 +161,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'WhatsApp booking draft opened',
|
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',
|
color: 'success',
|
||||||
icon: 'i-lucide-check-circle-2'
|
icon: 'i-lucide-check-circle-2'
|
||||||
})
|
})
|
||||||
@@ -217,11 +232,17 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UFormField label="Person In Charge">
|
<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>
|
</UFormField>
|
||||||
|
|
||||||
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
|
<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>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<UContainer class="py-10">
|
<UContainer class="py-10 lg:py-16">
|
||||||
<div class="mx-auto max-w-md">
|
<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>
|
<template #header>
|
||||||
<h1 class="text-2xl font-bold text-highlighted">
|
<div class="space-y-2">
|
||||||
Staff Login
|
<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>
|
</h1>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
||||||
@@ -35,9 +38,29 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
label="Sign In"
|
label="Sign In"
|
||||||
size="xl"
|
size="xl"
|
||||||
|
:loading="passwordPending"
|
||||||
class="w-full justify-center"
|
class="w-full justify-center"
|
||||||
/>
|
/>
|
||||||
</UForm>
|
</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>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
@@ -46,13 +69,22 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'guest'
|
||||||
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuth()
|
||||||
|
const apiClient = useApiClient()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
remember: true
|
remember: true
|
||||||
})
|
})
|
||||||
|
const passwordPending = ref(false)
|
||||||
|
const passkeyPending = ref(false)
|
||||||
|
|
||||||
function validateLogin(state: typeof form): FormError[] {
|
function validateLogin(state: typeof form): FormError[] {
|
||||||
const errors: FormError[] = []
|
const errors: FormError[] = []
|
||||||
@@ -68,14 +100,96 @@ function validateLogin(state: typeof form): FormError[] {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(event: FormSubmitEvent<typeof form>) {
|
async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>) {
|
||||||
toast.add({
|
if (!user) {
|
||||||
title: 'Authentication is not wired yet',
|
return
|
||||||
description: 'This page is ready for backend integration, but sign-in is still a placeholder.',
|
}
|
||||||
color: 'warning',
|
|
||||||
icon: 'i-lucide-info'
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
event.preventDefault()
|
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>
|
</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 },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/ui'],
|
modules: ['@nuxt/ui'],
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
runtimeConfig: {
|
||||||
|
databaseUrl: '',
|
||||||
|
redisUrl: '',
|
||||||
|
sessionCookieName: 'dinner_ticket_session',
|
||||||
|
public: {
|
||||||
|
appUrl: '',
|
||||||
|
rpName: 'Dinner Ticket System'
|
||||||
|
}
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
|
"start": "node .output/server/index.mjs",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "4.6.1",
|
"@nuxt/ui": "4.6.1",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"redis": "^5.11.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
},
|
},
|
||||||
|
|||||||
289
pnpm-lock.yaml
generated
289
pnpm-lock.yaml
generated
@@ -11,9 +11,21 @@ importers:
|
|||||||
'@nuxt/ui':
|
'@nuxt/ui':
|
||||||
specifier: 4.6.1
|
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)
|
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:
|
nuxt:
|
||||||
specifier: ^4.4.2
|
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)
|
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:
|
vue:
|
||||||
specifier: ^3.5.32
|
specifier: ^3.5.32
|
||||||
version: 3.5.32(typescript@6.0.2)
|
version: 3.5.32(typescript@6.0.2)
|
||||||
@@ -371,6 +383,9 @@ packages:
|
|||||||
'@floating-ui/vue@1.1.11':
|
'@floating-ui/vue@1.1.11':
|
||||||
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
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':
|
'@iconify/collections@1.0.671':
|
||||||
resolution: {integrity: sha512-+UO1BvRCf2zpuKg6REnJfgDjKvtMGjmTFKIZSRhOLK3A6sAFo1ZDB+AjDE1g0lzhKZ4KZsYM7b5tCHFFvalQQA==}
|
resolution: {integrity: sha512-+UO1BvRCf2zpuKg6REnJfgDjKvtMGjmTFKIZSRhOLK3A6sAFo1ZDB+AjDE1g0lzhKZ4KZsYM7b5tCHFFvalQQA==}
|
||||||
|
|
||||||
@@ -427,6 +442,9 @@ packages:
|
|||||||
'@kwsites/promise-deferred@1.1.1':
|
'@kwsites/promise-deferred@1.1.1':
|
||||||
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
|
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':
|
'@mapbox/node-pre-gyp@2.0.3':
|
||||||
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
|
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1024,6 +1042,43 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||||
engines: {node: '>= 10.0.0'}
|
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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1040,6 +1095,39 @@ packages:
|
|||||||
'@poppinss/exception@1.2.3':
|
'@poppinss/exception@1.2.3':
|
||||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
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':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
@@ -1252,6 +1340,13 @@ packages:
|
|||||||
'@simple-git/argv-parser@1.1.0':
|
'@simple-git/argv-parser@1.1.0':
|
||||||
resolution: {integrity: sha512-sUKOu2lb5vGIWADNNLpscyj07DAeQZU3KLbnE2Tj53tW6BbDQKMly2CCfnR4oYzqtRELCPWfwaPg+Q0T8qfKBg==}
|
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':
|
'@sindresorhus/is@7.2.0':
|
||||||
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1838,6 +1933,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
asn1js@3.0.7:
|
||||||
|
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
ast-kit@2.2.0:
|
ast-kit@2.2.0:
|
||||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@@ -3292,6 +3391,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres@3.4.9:
|
||||||
|
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
powershell-utils@0.1.0:
|
powershell-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3369,6 +3472,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
@@ -3411,6 +3521,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
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:
|
regexp-tree@0.1.27:
|
||||||
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
|
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3708,9 +3825,16 @@ packages:
|
|||||||
tr46@0.0.3:
|
tr46@0.0.3:
|
||||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
|
tslib@1.14.1:
|
||||||
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
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:
|
type-fest@5.5.0:
|
||||||
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
|
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -4475,6 +4599,8 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@hexagon/base64@1.1.28': {}
|
||||||
|
|
||||||
'@iconify/collections@1.0.671':
|
'@iconify/collections@1.0.671':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -4547,6 +4673,8 @@ snapshots:
|
|||||||
|
|
||||||
'@kwsites/promise-deferred@1.1.1': {}
|
'@kwsites/promise-deferred@1.1.1': {}
|
||||||
|
|
||||||
|
'@levischuck/tiny-cbor@0.2.11': {}
|
||||||
|
|
||||||
'@mapbox/node-pre-gyp@2.0.3':
|
'@mapbox/node-pre-gyp@2.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
consola: 3.4.2
|
consola: 3.4.2
|
||||||
@@ -5321,6 +5449,102 @@ snapshots:
|
|||||||
'@parcel/watcher-win32-ia32': 2.5.6
|
'@parcel/watcher-win32-ia32': 2.5.6
|
||||||
'@parcel/watcher-win32-x64': 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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5338,6 +5562,26 @@ snapshots:
|
|||||||
|
|
||||||
'@poppinss/exception@1.2.3': {}
|
'@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': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.15': {}
|
'@rolldown/pluginutils@1.0.0-rc.15': {}
|
||||||
@@ -5488,6 +5732,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@simple-git/args-pathspec': 1.0.3
|
'@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/is@7.2.0': {}
|
||||||
|
|
||||||
'@sindresorhus/merge-streams@4.0.0': {}
|
'@sindresorhus/merge-streams@4.0.0': {}
|
||||||
@@ -6098,6 +6355,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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:
|
ast-kit@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
@@ -7748,6 +8011,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres@3.4.9: {}
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
pretty-bytes@7.1.0: {}
|
pretty-bytes@7.1.0: {}
|
||||||
@@ -7861,6 +8126,12 @@ snapshots:
|
|||||||
|
|
||||||
punycode.js@2.3.1: {}
|
punycode.js@2.3.1: {}
|
||||||
|
|
||||||
|
pvtsutils@1.3.6:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
pvutils@1.1.5: {}
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@@ -7906,6 +8177,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
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: {}
|
regexp-tree@0.1.27: {}
|
||||||
|
|
||||||
reka-ui@2.9.3(vue@3.5.32(typescript@6.0.2)):
|
reka-ui@2.9.3(vue@3.5.32(typescript@6.0.2)):
|
||||||
@@ -8232,8 +8515,14 @@ snapshots:
|
|||||||
|
|
||||||
tr46@0.0.3: {}
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tsyringe@4.10.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 1.14.1
|
||||||
|
|
||||||
type-fest@5.5.0:
|
type-fest@5.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tagged-tag: 1.0.0
|
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