feat: implement auth system, passkeys, and user management

Add PostgreSQL and Redis integration for users and sessions
Implement password and WebAuthn passkey login flows
Add Docker stack, super-admin seeding, and protected routes
This commit is contained in:
2026-04-12 20:16:43 +08:00
parent a649c509c2
commit 377a9617be
45 changed files with 3620 additions and 104 deletions

View File

@@ -1,11 +1,14 @@
<template>
<UContainer class="py-10">
<UContainer class="py-10 lg:py-16">
<div class="mx-auto max-w-md">
<UCard class="border border-default bg-default">
<UCard class="border border-default bg-default shadow-sm">
<template #header>
<h1 class="text-2xl font-bold text-highlighted">
Staff Login
</h1>
<div class="space-y-2">
<UBadge label="Staff Access" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-3xl font-bold text-highlighted">
Login to the management system
</h1>
</div>
</template>
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
@@ -35,9 +38,29 @@
type="submit"
label="Sign In"
size="xl"
:loading="passwordPending"
class="w-full justify-center"
/>
</UForm>
<div class="my-6 flex items-center gap-3">
<div class="h-px flex-1 bg-default" />
<span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted">or</span>
<div class="h-px flex-1 bg-default" />
</div>
<div class="space-y-4">
<UButton
label="Sign In With Passkey"
color="neutral"
variant="outline"
size="xl"
class="w-full justify-center"
icon="i-lucide-fingerprint"
:loading="passkeyPending"
@click="loginWithPasskey"
/>
</div>
</UCard>
</div>
</UContainer>
@@ -46,13 +69,22 @@
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
definePageMeta({
middleware: 'guest'
})
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const apiClient = useApiClient()
const form = reactive({
username: '',
password: '',
remember: true
})
const passwordPending = ref(false)
const passkeyPending = ref(false)
function validateLogin(state: typeof form): FormError[] {
const errors: FormError[] = []
@@ -68,14 +100,96 @@ function validateLogin(state: typeof form): FormError[] {
return errors
}
function onSubmit(event: FormSubmitEvent<typeof form>) {
toast.add({
title: 'Authentication is not wired yet',
description: 'This page is ready for backend integration, but sign-in is still a placeholder.',
color: 'warning',
icon: 'i-lucide-info'
})
async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>) {
if (!user) {
return
}
const target = user.mustChangePassword || user.needsPasskeySetup
? '/security'
: user.role === 'super_admin'
? '/management/users'
: '/security'
await router.push(target)
}
async function onSubmit(event: FormSubmitEvent<typeof form>) {
event.preventDefault()
if (passwordPending.value) {
return
}
passwordPending.value = true
try {
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/login', {
method: 'POST',
body: {
username: form.username,
password: form.password,
remember: form.remember
}
})
auth.setUser(response.user)
await finishLogin(response.user)
} catch (error: any) {
toast.add({
title: 'Login failed',
description: error?.data?.statusMessage || 'Unable to sign in with username and password.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passwordPending.value = false
}
}
async function loginWithPasskey() {
if (passkeyPending.value) {
return
}
passkeyPending.value = true
try {
const { browserSupportsWebAuthn, startAuthentication } = await import('@simplewebauthn/browser')
if (!browserSupportsWebAuthn()) {
throw new Error('This browser does not support WebAuthn passkeys.')
}
const optionsResponse = await apiClient<{
options: Record<string, any>
challengeToken: string
}>('/api/auth/passkey/login/options', {
method: 'POST'
})
const credential = await startAuthentication({
optionsJSON: optionsResponse.options
})
const verification = await apiClient<{ user: typeof auth.user.value }>('/api/auth/passkey/login/verify', {
method: 'POST',
body: {
response: credential,
challengeToken: optionsResponse.challengeToken,
remember: form.remember
}
})
auth.setUser(verification.user)
await finishLogin(verification.user)
} catch (error: any) {
toast.add({
title: 'Passkey login failed',
description: error?.data?.statusMessage || error?.message || 'Unable to complete passkey login.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passkeyPending.value = false
}
}
</script>