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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user