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:
10
app/composables/useApiClient.ts
Normal file
10
app/composables/useApiClient.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function useApiClient() {
|
||||
return async function apiClient<T>(url: string, options?: Parameters<typeof $fetch<T>>[1]) {
|
||||
if (import.meta.server) {
|
||||
const requestFetch = useRequestFetch()
|
||||
return await requestFetch<T>(url, options)
|
||||
}
|
||||
|
||||
return await $fetch<T>(url, options)
|
||||
}
|
||||
}
|
||||
54
app/composables/useAuth.ts
Normal file
54
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AuthUser } from '~~/shared/auth'
|
||||
|
||||
export function useAuth() {
|
||||
const user = useState<AuthUser | null>('auth:user', () => null)
|
||||
const loaded = useState<boolean>('auth:loaded', () => false)
|
||||
const loading = useState<boolean>('auth:loading', () => false)
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value))
|
||||
const isSuperAdmin = computed(() => user.value?.role === 'super_admin')
|
||||
const needsOnboarding = computed(() => {
|
||||
return Boolean(user.value && (user.value.mustChangePassword || user.value.needsPasskeySetup))
|
||||
})
|
||||
|
||||
async function fetchSession(force = false) {
|
||||
if (loaded.value && !force) {
|
||||
return user.value
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ user: AuthUser | null }>('/api/auth/me')
|
||||
user.value = response.user
|
||||
loaded.value = true
|
||||
return user.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(nextUser: AuthUser | null) {
|
||||
user.value = nextUser
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
user.value = null
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loaded,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isSuperAdmin,
|
||||
needsOnboarding,
|
||||
fetchSession,
|
||||
refreshSession: () => fetchSession(true),
|
||||
setUser,
|
||||
clearUser
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,416 @@
|
||||
<template>
|
||||
<div class="min-h-dvh bg-default text-default">
|
||||
<header class="border-b border-default bg-default">
|
||||
<UContainer class="flex items-center justify-between gap-4 py-6">
|
||||
<UBadge
|
||||
label="Event Ticket System"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="rounded-full px-3 py-1 font-semibold"
|
||||
<div class="relative min-h-dvh bg-default text-default">
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-primary/10 via-primary/0 to-transparent opacity-80" />
|
||||
|
||||
<template v-if="auth.user.value">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<button
|
||||
v-if="mobileMenuOpen"
|
||||
type="button"
|
||||
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden"
|
||||
aria-label="Close navigation"
|
||||
@click="mobileMenuOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<UButton
|
||||
id="loginBtn"
|
||||
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
||||
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
|
||||
color="neutral"
|
||||
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||
/>
|
||||
</UContainer>
|
||||
</header>
|
||||
<Transition
|
||||
enter-active-class="transform transition duration-300 ease-out"
|
||||
enter-from-class="-translate-x-6 opacity-0"
|
||||
enter-to-class="translate-x-0 opacity-100"
|
||||
leave-active-class="transform transition duration-200 ease-in"
|
||||
leave-from-class="translate-x-0 opacity-100"
|
||||
leave-to-class="-translate-x-6 opacity-0"
|
||||
>
|
||||
<aside
|
||||
v-if="mobileMenuOpen"
|
||||
class="fixed inset-y-2 left-2 z-50 flex w-[min(20rem,calc(100vw-1rem))] flex-col rounded-[28px] border border-default/80 bg-default/96 p-3 shadow-2xl backdrop-blur-xl lg:hidden"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 rounded-3xl border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
||||
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
||||
</div>
|
||||
|
||||
<UMain>
|
||||
<slot />
|
||||
</UMain>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="border-t border-default bg-default">
|
||||
<UContainer class="py-5 text-center text-sm text-muted">
|
||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||
</UContainer>
|
||||
</footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
class="rounded-full"
|
||||
aria-label="Close navigation"
|
||||
@click="mobileMenuOpen = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="mt-4 grid gap-2">
|
||||
<NuxtLink
|
||||
v-for="item in systemMenuItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
||||
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
|
||||
:class="mobileMenuItemClasses(item)"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
|
||||
:class="mobileMenuIconClasses(item)"
|
||||
>
|
||||
<UIcon :name="item.icon" class="size-5" />
|
||||
</div>
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="ml-auto size-4 opacity-50 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto space-y-3 border-t border-default pt-3">
|
||||
<div class="rounded-3xl border border-default bg-default/90 px-4 py-4 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
|
||||
<UIcon name="i-lucide-user-round" class="size-5" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
</div>
|
||||
<div class="truncate text-sm text-muted">
|
||||
@{{ auth.user.value.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:label="userRoleLabel"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
class="mt-3 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
to="/"
|
||||
label="Public"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-house"
|
||||
class="w-full justify-start rounded-2xl"
|
||||
@click="mobileMenuOpen = false"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:loading="logoutPending"
|
||||
label="Logout"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-log-out"
|
||||
class="w-full justify-start rounded-2xl"
|
||||
@click="logout"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
|
||||
<div class="relative lg:grid lg:min-h-dvh lg:grid-cols-[17.5rem_minmax(0,1fr)]">
|
||||
<aside class="sticky top-0 hidden h-dvh flex-col border-r border-default/80 bg-default/92 px-5 py-5 backdrop-blur-xl lg:flex">
|
||||
<div class="rounded-[28px] border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
||||
<UIcon name="i-lucide-grid-2x2" class="size-6" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mt-6 grid gap-2">
|
||||
<NuxtLink
|
||||
v-for="item in systemMenuItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
||||
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
|
||||
:class="desktopMenuItemClasses(item)"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
|
||||
:class="desktopMenuIconClasses(item)"
|
||||
>
|
||||
<UIcon :name="item.icon" class="size-5" />
|
||||
</div>
|
||||
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full transition-all duration-200"
|
||||
:class="isMenuItemActive(item) ? 'bg-primary shadow-[0_0_0_4px_rgba(var(--ui-primary-rgb),0.14)]' : 'bg-default group-hover:bg-primary/30'"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="rounded-[28px] border border-default bg-default/90 px-4 py-4 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
|
||||
<UIcon name="i-lucide-user-round" class="size-5" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
</div>
|
||||
<div class="truncate text-sm text-muted">
|
||||
@{{ auth.user.value.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:label="userRoleLabel"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
class="mt-3 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<UButton
|
||||
to="/"
|
||||
label="Public"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-house"
|
||||
class="justify-start rounded-2xl"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:loading="logoutPending"
|
||||
label="Logout"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-log-out"
|
||||
class="justify-start rounded-2xl"
|
||||
@click="logout"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="sticky top-0 z-30 border-b border-default/80 bg-default/92 backdrop-blur-xl lg:hidden">
|
||||
<UContainer class="py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
||||
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="shrink-0 rounded-full"
|
||||
:label="mobileMenuOpen ? 'Close' : 'Menu'"
|
||||
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
aria-label="Toggle navigation"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
/>
|
||||
</div>
|
||||
</UContainer>
|
||||
</header>
|
||||
|
||||
<UMain class="relative z-10">
|
||||
<slot />
|
||||
</UMain>
|
||||
|
||||
<footer class="border-t border-default bg-default/96">
|
||||
<UContainer class="py-5 text-center text-sm text-muted">
|
||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||
</UContainer>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="relative border-b border-default/80 bg-default/92 backdrop-blur-xl">
|
||||
<UContainer class="py-4 md:py-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
|
||||
Dinner Ticket System
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
id="loginBtn"
|
||||
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
||||
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
|
||||
color="neutral"
|
||||
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</UContainer>
|
||||
</header>
|
||||
|
||||
<UMain class="relative z-10">
|
||||
<slot />
|
||||
</UMain>
|
||||
|
||||
<footer class="border-t border-default bg-default/96">
|
||||
<UContainer class="py-5 text-center text-sm text-muted">
|
||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||
</UContainer>
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface SystemMenuItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
requiresSuperAdmin?: boolean
|
||||
matches: (path: string) => boolean
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const auth = useAuth()
|
||||
const apiClient = useApiClient()
|
||||
const logoutPending = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
await auth.fetchSession()
|
||||
|
||||
const allSystemMenuItems: SystemMenuItem[] = [
|
||||
{
|
||||
label: 'Security',
|
||||
to: '/security',
|
||||
icon: 'i-lucide-shield-check',
|
||||
matches: (path) => path.startsWith('/security')
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
to: '/management/users',
|
||||
icon: 'i-lucide-users',
|
||||
requiresSuperAdmin: true,
|
||||
matches: (path) => path.startsWith('/management/users')
|
||||
}
|
||||
]
|
||||
|
||||
const systemMenuItems = computed(() => {
|
||||
return allSystemMenuItems.filter((item) => {
|
||||
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
|
||||
})
|
||||
})
|
||||
|
||||
const userRoleLabel = computed(() => {
|
||||
return auth.isSuperAdmin.value ? 'Super Admin' : 'Staff'
|
||||
})
|
||||
|
||||
function isMenuItemActive(item: SystemMenuItem) {
|
||||
return item.matches(route.path)
|
||||
}
|
||||
|
||||
function desktopMenuItemClasses(item: SystemMenuItem) {
|
||||
return isMenuItemActive(item)
|
||||
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-default bg-default/80 text-default hover:border-primary/20 hover:bg-muted/60 hover:text-highlighted'
|
||||
}
|
||||
|
||||
function desktopMenuIconClasses(item: SystemMenuItem) {
|
||||
return isMenuItemActive(item)
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-muted text-muted group-hover:bg-primary/10 group-hover:text-primary'
|
||||
}
|
||||
|
||||
function mobileMenuItemClasses(item: SystemMenuItem) {
|
||||
return isMenuItemActive(item)
|
||||
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-default bg-default/88 text-default'
|
||||
}
|
||||
|
||||
function mobileMenuIconClasses(item: SystemMenuItem) {
|
||||
return isMenuItemActive(item)
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-muted text-muted'
|
||||
}
|
||||
|
||||
watch(() => route.path, () => {
|
||||
mobileMenuOpen.value = false
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
if (logoutPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
logoutPending.value = true
|
||||
|
||||
try {
|
||||
await apiClient('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
auth.clearUser()
|
||||
await router.push('/login')
|
||||
|
||||
toast.add({
|
||||
title: 'Signed out',
|
||||
description: 'Your session has been cleared.',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Logout failed',
|
||||
description: error?.data?.statusMessage || 'Unable to end the current session.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
logoutPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
12
app/middleware/auth.ts
Normal file
12
app/middleware/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
|
||||
if (!auth.user.value) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (to.path !== '/security' && auth.needsOnboarding.value) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
})
|
||||
14
app/middleware/guest.ts
Normal file
14
app/middleware/guest.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
|
||||
if (!auth.user.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.needsOnboarding.value) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
|
||||
return navigateTo(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
||||
})
|
||||
16
app/middleware/super-admin.ts
Normal file
16
app/middleware/super-admin.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuth()
|
||||
await auth.fetchSession()
|
||||
|
||||
if (!auth.user.value) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (auth.needsOnboarding.value) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
|
||||
if (!auth.isSuperAdmin.value) {
|
||||
return navigateTo('/security')
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
|
||||
|
||||
type BookingMode = 'table' | 'pax'
|
||||
type TicketType = 'vip' | 'supporter'
|
||||
|
||||
const toast = useToast()
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const eventDetails = [
|
||||
{
|
||||
@@ -50,16 +53,13 @@ const ticketCatalog = [
|
||||
}
|
||||
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
||||
|
||||
const personInCharge = [
|
||||
{
|
||||
label: 'Xiaomai',
|
||||
value: '601157753558'
|
||||
},
|
||||
{
|
||||
label: 'Lily',
|
||||
value: '60172661198'
|
||||
}
|
||||
]
|
||||
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||
const personInCharge = computed(() => {
|
||||
return contactsResponse.contacts.map((contact) => ({
|
||||
label: contact.fullName,
|
||||
value: contact.id
|
||||
}))
|
||||
})
|
||||
|
||||
const priceFormatter = new Intl.NumberFormat('en-MY', {
|
||||
style: 'currency',
|
||||
@@ -76,7 +76,11 @@ const form = reactive({
|
||||
ticketType: 'vip' as TicketType
|
||||
})
|
||||
|
||||
const selectedPersonInCharge = ref(personInCharge[0]?.value ?? '')
|
||||
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
||||
|
||||
const selectedPersonInChargeRecord = computed(() => {
|
||||
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTicket = computed(() => {
|
||||
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
|
||||
@@ -101,7 +105,7 @@ function validateBooking(state: typeof form): FormError[] {
|
||||
|
||||
if (!state.phone.trim()) {
|
||||
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
|
||||
} else if (!/^\+?[0-9\s-]{8,15}$/.test(state.phone.trim())) {
|
||||
} else if (!isValidPhoneNumber(state.phone.trim())) {
|
||||
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
|
||||
}
|
||||
|
||||
@@ -129,9 +133,20 @@ function buildBookingMessage() {
|
||||
}
|
||||
|
||||
function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
const selectedPic = personInCharge.find((item) => item.value === selectedPersonInCharge.value) ?? personInCharge[0]
|
||||
const selectedPic = selectedPersonInChargeRecord.value
|
||||
|
||||
if (!selectedPic) {
|
||||
toast.add({
|
||||
title: 'No person in charge available',
|
||||
description: 'Add a user with a phone number in the management page first.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const encodedMessage = encodeURIComponent(buildBookingMessage())
|
||||
const whatsappUrl = `https://wa.me/${selectedPic.value}?text=${encodedMessage}`
|
||||
const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}`
|
||||
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
|
||||
|
||||
if (!bookingWindow) {
|
||||
@@ -146,7 +161,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
toast.add({
|
||||
title: 'WhatsApp booking draft opened',
|
||||
description: `Your reservation details were sent to ${selectedPic.label}.`,
|
||||
description: `Your reservation details were sent to ${selectedPic.fullName}.`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
@@ -217,11 +232,17 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
</div>
|
||||
|
||||
<UFormField label="Person In Charge">
|
||||
<USelect v-model="selectedPersonInCharge" size="xl" class="w-full" :items="personInCharge" />
|
||||
<USelect
|
||||
v-model="selectedPersonInCharge"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
:items="personInCharge"
|
||||
:disabled="!personInCharge.length"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
|
||||
class="w-full justify-center" />
|
||||
class="w-full justify-center" :disabled="!selectedPersonInCharge" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<UContainer class="py-10">
|
||||
<UContainer class="py-10 lg:py-16">
|
||||
<div class="mx-auto max-w-md">
|
||||
<UCard class="border border-default bg-default">
|
||||
<UCard class="border border-default bg-default shadow-sm">
|
||||
<template #header>
|
||||
<h1 class="text-2xl font-bold text-highlighted">
|
||||
Staff Login
|
||||
</h1>
|
||||
<div class="space-y-2">
|
||||
<UBadge label="Staff Access" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-3xl font-bold text-highlighted">
|
||||
Login to the management system
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
||||
@@ -35,9 +38,29 @@
|
||||
type="submit"
|
||||
label="Sign In"
|
||||
size="xl"
|
||||
:loading="passwordPending"
|
||||
class="w-full justify-center"
|
||||
/>
|
||||
</UForm>
|
||||
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-default" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted">or</span>
|
||||
<div class="h-px flex-1 bg-default" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UButton
|
||||
label="Sign In With Passkey"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xl"
|
||||
class="w-full justify-center"
|
||||
icon="i-lucide-fingerprint"
|
||||
:loading="passkeyPending"
|
||||
@click="loginWithPasskey"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
@@ -46,13 +69,22 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: true
|
||||
})
|
||||
const passwordPending = ref(false)
|
||||
const passkeyPending = ref(false)
|
||||
|
||||
function validateLogin(state: typeof form): FormError[] {
|
||||
const errors: FormError[] = []
|
||||
@@ -68,14 +100,96 @@ function validateLogin(state: typeof form): FormError[] {
|
||||
return errors
|
||||
}
|
||||
|
||||
function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||
toast.add({
|
||||
title: 'Authentication is not wired yet',
|
||||
description: 'This page is ready for backend integration, but sign-in is still a placeholder.',
|
||||
color: 'warning',
|
||||
icon: 'i-lucide-info'
|
||||
})
|
||||
async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>) {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = user.mustChangePassword || user.needsPasskeySetup
|
||||
? '/security'
|
||||
: user.role === 'super_admin'
|
||||
? '/management/users'
|
||||
: '/security'
|
||||
|
||||
await router.push(target)
|
||||
}
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (passwordPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passwordPending.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
remember: form.remember
|
||||
}
|
||||
})
|
||||
|
||||
auth.setUser(response.user)
|
||||
await finishLogin(response.user)
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Login failed',
|
||||
description: error?.data?.statusMessage || 'Unable to sign in with username and password.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
passwordPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
if (passkeyPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passkeyPending.value = true
|
||||
|
||||
try {
|
||||
const { browserSupportsWebAuthn, startAuthentication } = await import('@simplewebauthn/browser')
|
||||
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
throw new Error('This browser does not support WebAuthn passkeys.')
|
||||
}
|
||||
|
||||
const optionsResponse = await apiClient<{
|
||||
options: Record<string, any>
|
||||
challengeToken: string
|
||||
}>('/api/auth/passkey/login/options', {
|
||||
method: 'POST'
|
||||
})
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: optionsResponse.options
|
||||
})
|
||||
const verification = await apiClient<{ user: typeof auth.user.value }>('/api/auth/passkey/login/verify', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
response: credential,
|
||||
challengeToken: optionsResponse.challengeToken,
|
||||
remember: form.remember
|
||||
}
|
||||
})
|
||||
|
||||
auth.setUser(verification.user)
|
||||
await finishLogin(verification.user)
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Passkey login failed',
|
||||
description: error?.data?.statusMessage || error?.message || 'Unable to complete passkey login.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
passkeyPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
469
app/pages/management/users/index.vue
Normal file
469
app/pages/management/users/index.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<UContainer class="py-8">
|
||||
<div class="mx-auto max-w-6xl space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<UBadge label="Super Admin" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-2xl font-bold text-highlighted sm:text-3xl">
|
||||
User management
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="issuedPasswordMessage"
|
||||
title="Temporary password issued"
|
||||
:description="issuedPasswordMessage"
|
||||
color="success"
|
||||
icon="i-lucide-key-round"
|
||||
/>
|
||||
|
||||
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'p-0 sm:p-0' }">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="w-full sm:w-72"
|
||||
placeholder="Search name, username, or phone"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
label="Refresh"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-refresh-cw"
|
||||
size="lg"
|
||||
:loading="loadingUsers"
|
||||
@click="refreshUsers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Add User"
|
||||
size="lg"
|
||||
icon="i-lucide-plus"
|
||||
class="justify-center"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<UTable
|
||||
:data="filteredUsers"
|
||||
:columns="columns"
|
||||
:loading="loadingUsers"
|
||||
:empty="searchQuery.trim() ? 'No matching users found.' : 'No users available.'"
|
||||
sticky="header"
|
||||
caption="Users"
|
||||
class="min-w-[820px]"
|
||||
>
|
||||
<template #fullName-cell="{ row }">
|
||||
<div class="min-w-0 space-y-0.5 py-1">
|
||||
<div class="font-semibold leading-tight text-highlighted">
|
||||
{{ row.original.fullName }}
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
@{{ row.original.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #phoneNumber-cell="{ row }">
|
||||
<span class="text-sm" :class="row.original.phoneNumber ? 'text-default' : 'text-muted'">
|
||||
{{ row.original.phoneNumber || 'Not set' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #role-cell="{ row }">
|
||||
<UBadge
|
||||
:label="row.original.role === 'super_admin' ? 'Super Admin' : 'Staff'"
|
||||
:color="row.original.role === 'super_admin' ? 'primary' : 'neutral'"
|
||||
variant="soft"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<div class="space-y-1.5 py-1">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UBadge
|
||||
:label="row.original.mustChangePassword ? 'Password reset' : 'Password ready'"
|
||||
:color="row.original.mustChangePassword ? 'warning' : 'success'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
<UBadge
|
||||
:label="row.original.needsPasskeySetup ? 'Passkey pending' : 'Passkey ready'"
|
||||
:color="row.original.needsPasskeySetup ? 'warning' : 'success'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted">
|
||||
{{ row.original.passkeyCount }} passkey{{ row.original.passkeyCount === 1 ? '' : 's' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #lastLoginAt-cell="{ row }">
|
||||
<span class="text-xs text-muted sm:text-sm">
|
||||
{{ formatDate(row.original.lastLoginAt) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex flex-wrap justify-end gap-1.5 py-1">
|
||||
<UButton
|
||||
label="Edit"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
@click="openEditModal(row.original)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
label="Reset Password"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-key-round"
|
||||
size="sm"
|
||||
:loading="resettingUserId === row.original.id"
|
||||
@click="resetPassword(row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal
|
||||
v-model:open="editorOpen"
|
||||
:title="isEditMode ? 'Edit User' : 'Add User'"
|
||||
:dismissible="!savingUser"
|
||||
:close="!savingUser"
|
||||
:content="{ class: 'sm:max-w-xl' }"
|
||||
>
|
||||
<template #body>
|
||||
<UForm :state="userForm" :validate="validateUserForm" class="space-y-4" @submit="saveUser">
|
||||
<UFormField name="fullName" label="Display Name" required>
|
||||
<UInput v-model="userForm.fullName" size="lg" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="username" label="Username" required>
|
||||
<UInput
|
||||
v-model="userForm.username"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:disabled="isEditMode"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="phoneNumber" label="Phone Number" required>
|
||||
<UInput
|
||||
v-model="userForm.phoneNumber"
|
||||
size="lg"
|
||||
type="tel"
|
||||
class="w-full"
|
||||
placeholder="e.g. 0123456789"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="role" label="Role" required>
|
||||
<USelect
|
||||
v-model="userForm.role"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:items="roleOptions"
|
||||
:disabled="isEditingCurrentUser"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
label="Cancel"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="justify-center"
|
||||
:disabled="savingUser"
|
||||
@click="closeEditor"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="isEditMode ? 'Save Changes' : 'Create User'"
|
||||
class="justify-center"
|
||||
:loading="savingUser"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</UContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import {
|
||||
USERNAME_PATTERN,
|
||||
isValidPhoneNumber,
|
||||
normalizePhoneNumber,
|
||||
type ManagedUser,
|
||||
type UserRole
|
||||
} from '~~/shared/auth'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const apiClient = useApiClient()
|
||||
const auth = useAuth()
|
||||
|
||||
const users = ref<ManagedUser[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const savingUser = ref(false)
|
||||
const resettingUserId = ref<string | null>(null)
|
||||
const issuedPasswordMessage = ref('')
|
||||
const searchQuery = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const editorMode = ref<'create' | 'edit'>('create')
|
||||
const editingUserId = ref<string | null>(null)
|
||||
|
||||
const userForm = reactive({
|
||||
fullName: '',
|
||||
username: '',
|
||||
phoneNumber: '',
|
||||
role: 'staff' as UserRole
|
||||
})
|
||||
|
||||
const roleOptions = [
|
||||
{ label: 'Staff', value: 'staff' },
|
||||
{ label: 'Super Admin', value: 'super_admin' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'fullName', header: 'Display Name' },
|
||||
{ accessorKey: 'phoneNumber', header: 'PIC Phone' },
|
||||
{ accessorKey: 'role', header: 'Role' },
|
||||
{ id: 'status', header: 'Status' },
|
||||
{ accessorKey: 'lastLoginAt', header: 'Last Login' },
|
||||
{ id: 'actions', header: 'Actions' }
|
||||
]
|
||||
|
||||
const isEditMode = computed(() => editorMode.value === 'edit')
|
||||
const isEditingCurrentUser = computed(() => {
|
||||
return isEditMode.value && editingUserId.value === auth.user.value?.id
|
||||
})
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (!keyword) {
|
||||
return users.value
|
||||
}
|
||||
|
||||
return users.value.filter((user) => {
|
||||
return [
|
||||
user.fullName,
|
||||
user.username,
|
||||
user.phoneNumber || '',
|
||||
user.role
|
||||
].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
await refreshUsers()
|
||||
|
||||
function resetUserForm() {
|
||||
userForm.fullName = ''
|
||||
userForm.username = ''
|
||||
userForm.phoneNumber = ''
|
||||
userForm.role = 'staff'
|
||||
editingUserId.value = null
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editorMode.value = 'create'
|
||||
resetUserForm()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEditModal(user: ManagedUser) {
|
||||
editorMode.value = 'edit'
|
||||
editingUserId.value = user.id
|
||||
userForm.fullName = user.fullName
|
||||
userForm.username = user.username
|
||||
userForm.phoneNumber = user.phoneNumber || ''
|
||||
userForm.role = user.role
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (savingUser.value) {
|
||||
return
|
||||
}
|
||||
|
||||
editorOpen.value = false
|
||||
resetUserForm()
|
||||
}
|
||||
|
||||
function validateUserForm(state: typeof userForm): FormError[] {
|
||||
const errors: FormError[] = []
|
||||
|
||||
if (state.fullName.trim().length < 2) {
|
||||
errors.push({ name: 'fullName', message: 'Enter a display name with at least 2 characters.' })
|
||||
}
|
||||
|
||||
if (!isEditMode.value && !USERNAME_PATTERN.test(state.username.trim().toLowerCase())) {
|
||||
errors.push({ name: 'username', message: 'Use 3 to 32 lowercase letters, numbers, dot, dash, or underscore.' })
|
||||
}
|
||||
|
||||
if (!isValidPhoneNumber(state.phoneNumber)) {
|
||||
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with 8 to 15 digits.' })
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
if (loadingUsers.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingUsers.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users')
|
||||
users.value = response.users
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Unable to load users',
|
||||
description: error?.data?.statusMessage || 'The user list could not be loaded.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (savingUser.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingUser.value = true
|
||||
|
||||
try {
|
||||
if (isEditMode.value && editingUserId.value) {
|
||||
await apiClient(`/api/admin/users/${editingUserId.value}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
fullName: userForm.fullName.trim(),
|
||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||
role: userForm.role
|
||||
}
|
||||
})
|
||||
|
||||
if (editingUserId.value === auth.user.value?.id) {
|
||||
await auth.refreshSession()
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'User updated',
|
||||
description: `${userForm.fullName.trim()} has been updated.`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
} else {
|
||||
const response = await apiClient<{
|
||||
user: ManagedUser
|
||||
defaultPassword: string
|
||||
}>('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fullName: userForm.fullName.trim(),
|
||||
username: userForm.username.trim().toLowerCase(),
|
||||
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
|
||||
role: userForm.role
|
||||
}
|
||||
})
|
||||
|
||||
issuedPasswordMessage.value = `${response.user.fullName} was created with the temporary password ${response.defaultPassword}.`
|
||||
|
||||
toast.add({
|
||||
title: 'User created',
|
||||
description: `${response.user.fullName} can now sign in with the temporary password.`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
}
|
||||
|
||||
closeEditor()
|
||||
await refreshUsers()
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: isEditMode.value ? 'Update failed' : 'User creation failed',
|
||||
description: error?.data?.statusMessage || (isEditMode.value ? 'Unable to update this user.' : 'Unable to create the new user.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
savingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(user: ManagedUser) {
|
||||
if (resettingUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
resettingUserId.value = user.id
|
||||
|
||||
try {
|
||||
const response = await apiClient<{
|
||||
user: ManagedUser
|
||||
defaultPassword: string
|
||||
}>(`/api/admin/users/${user.id}/reset-password`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
issuedPasswordMessage.value = `${user.fullName}'s password was reset to ${response.defaultPassword}. They must change it after login.`
|
||||
|
||||
toast.add({
|
||||
title: 'Password reset',
|
||||
description: `${user.fullName} now has a fresh temporary password.`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
|
||||
await refreshUsers()
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Reset failed',
|
||||
description: error?.data?.statusMessage || 'Unable to reset this password.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
resettingUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Never'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-MY', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
268
app/pages/security/index.vue
Normal file
268
app/pages/security/index.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<UContainer class="py-8">
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UBadge label="Security" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-3xl font-bold text-highlighted">
|
||||
Password and passkey settings
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<UCard class="border border-default bg-default">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-highlighted">
|
||||
Change password
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<UForm :state="passwordForm" :validate="validatePasswordForm" class="space-y-5" @submit="changePassword">
|
||||
<UFormField name="currentPassword" label="Current Password" required>
|
||||
<UInput v-model="passwordForm.currentPassword" type="password" size="xl" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="newPassword" label="New Password" required>
|
||||
<UInput v-model="passwordForm.newPassword" type="password" size="xl" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="confirmPassword" label="Confirm New Password" required>
|
||||
<UInput v-model="passwordForm.confirmPassword" type="password" size="xl" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Update Password"
|
||||
size="xl"
|
||||
class="w-full justify-center"
|
||||
:loading="passwordPending"
|
||||
/>
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
<UCard class="border border-default bg-default">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-highlighted">
|
||||
Passkeys
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-2xl border border-default bg-muted/40 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-highlighted">
|
||||
Registered passkeys
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
{{ passkeys.length }} passkey{{ passkeys.length === 1 ? '' : 's' }} connected to this account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:label="passkeys.length > 0 ? 'Ready' : 'Required'"
|
||||
:color="passkeys.length > 0 ? 'success' : 'warning'"
|
||||
variant="soft"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Register New Passkey"
|
||||
size="xl"
|
||||
class="w-full justify-center"
|
||||
icon="i-lucide-fingerprint"
|
||||
:loading="passkeyPending"
|
||||
@click="registerPasskey"
|
||||
/>
|
||||
|
||||
<div v-if="passkeys.length" class="space-y-3">
|
||||
<div
|
||||
v-for="passkey in passkeys"
|
||||
:key="passkey.id"
|
||||
class="rounded-2xl border border-default bg-default px-4 py-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-highlighted">
|
||||
{{ passkey.label }}
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
Added {{ formatDate(passkey.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UBadge :label="passkey.deviceType === 'multiDevice' ? 'Synced' : 'Single Device'" color="neutral" variant="soft" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import { MIN_PASSWORD_LENGTH, type PasskeySummary } from '~~/shared/auth'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const passwordPending = ref(false)
|
||||
const passkeyPending = ref(false)
|
||||
const passkeys = ref<PasskeySummary[]>([])
|
||||
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
await fetchPasskeys()
|
||||
|
||||
function validatePasswordForm(state: typeof passwordForm): FormError[] {
|
||||
const errors: FormError[] = []
|
||||
|
||||
if (!state.currentPassword.trim()) {
|
||||
errors.push({ name: 'currentPassword', message: 'Enter your current password.' })
|
||||
}
|
||||
|
||||
if (state.newPassword.trim().length < MIN_PASSWORD_LENGTH) {
|
||||
errors.push({ name: 'newPassword', message: `Use at least ${MIN_PASSWORD_LENGTH} characters.` })
|
||||
}
|
||||
|
||||
if (state.confirmPassword.trim() !== state.newPassword.trim()) {
|
||||
errors.push({ name: 'confirmPassword', message: 'Confirmation does not match the new password.' })
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
async function fetchPasskeys() {
|
||||
const response = await apiClient<{ passkeys: PasskeySummary[] }>('/api/auth/passkeys')
|
||||
passkeys.value = response.passkeys
|
||||
}
|
||||
|
||||
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
|
||||
if (previouslyRequired && !auth.needsOnboarding.value) {
|
||||
router.push(auth.isSuperAdmin.value ? '/management/users' : '/security')
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(event: FormSubmitEvent<typeof passwordForm>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (passwordPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passwordPending.value = true
|
||||
const previouslyRequired = auth.needsOnboarding.value
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
}
|
||||
})
|
||||
|
||||
auth.setUser(response.user)
|
||||
passwordForm.currentPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
passwordForm.confirmPassword = ''
|
||||
|
||||
toast.add({
|
||||
title: 'Password updated',
|
||||
description: 'Your account password has been changed.',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
|
||||
maybeRedirectAfterOnboarding(previouslyRequired)
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Password update failed',
|
||||
description: error?.data?.statusMessage || 'Unable to update your password.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
passwordPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
if (passkeyPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passkeyPending.value = true
|
||||
const previouslyRequired = auth.needsOnboarding.value
|
||||
|
||||
try {
|
||||
const { browserSupportsWebAuthn, startRegistration } = await import('@simplewebauthn/browser')
|
||||
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
throw new Error('This browser does not support passkey registration.')
|
||||
}
|
||||
|
||||
const optionsResponse = await apiClient<{ options: Record<string, any> }>('/api/auth/passkey/register/options', {
|
||||
method: 'POST'
|
||||
})
|
||||
const credential = await startRegistration({
|
||||
optionsJSON: optionsResponse.options
|
||||
})
|
||||
const verification = await apiClient<{
|
||||
user: typeof auth.user.value
|
||||
passkeys: PasskeySummary[]
|
||||
}>('/api/auth/passkey/register/verify', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
response: credential
|
||||
}
|
||||
})
|
||||
|
||||
auth.setUser(verification.user)
|
||||
passkeys.value = verification.passkeys
|
||||
|
||||
toast.add({
|
||||
title: 'Passkey registered',
|
||||
description: 'You can now use this passkey to sign in.',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
|
||||
maybeRedirectAfterOnboarding(previouslyRequired)
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Passkey registration failed',
|
||||
description: error?.data?.statusMessage || error?.message || 'Unable to register a passkey.',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
passkeyPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Not available'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-MY', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user