Secure API endpoints with requireBookingManager authorization check Update confirmation page to prompt for login if unauthorized Add safe redirect handling to login and guest middleware
254 lines
7.1 KiB
Vue
254 lines
7.1 KiB
Vue
<template>
|
|
<UContainer class="page-shell-narrow">
|
|
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_27rem] lg:items-center">
|
|
<section class="page-header">
|
|
<UBadge :label="t('login.badge')" color="primary" variant="soft" class="page-eyebrow" />
|
|
<h1 class="page-title">
|
|
{{ t('login.title') }}
|
|
</h1>
|
|
<p class="page-description">
|
|
{{ t('layout.brand') }}
|
|
</p>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
|
<div class="surface-card rounded-lg p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-lucide-lock-keyhole" class="size-5" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-semibold text-highlighted">
|
|
{{ t('login.signIn') }}
|
|
</p>
|
|
<p class="text-sm text-muted">
|
|
{{ t('login.remember') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="surface-card rounded-lg p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-lucide-fingerprint" class="size-5" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-semibold text-highlighted">
|
|
{{ t('login.passkey') }}
|
|
</p>
|
|
<p class="text-sm text-muted">
|
|
{{ t('login.or') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<UCard class="surface-card overflow-hidden rounded-lg">
|
|
<template #header>
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-semibold text-primary">
|
|
{{ t('login.badge') }}
|
|
</p>
|
|
<h2 class="text-xl font-semibold text-highlighted">
|
|
{{ t('login.signIn') }}
|
|
</h2>
|
|
</div>
|
|
</template>
|
|
|
|
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
|
<UFormField name="username" :label="t('login.username')" required>
|
|
<UInput
|
|
v-model="form.username"
|
|
type="text"
|
|
size="xl"
|
|
class="w-full"
|
|
:placeholder="t('login.usernamePlaceholder')"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField name="password" :label="t('login.password')" required>
|
|
<UInput
|
|
v-model="form.password"
|
|
type="password"
|
|
size="xl"
|
|
class="w-full"
|
|
:placeholder="t('login.passwordPlaceholder')"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UCheckbox v-model="form.remember" :label="t('login.remember')" />
|
|
|
|
<UButton
|
|
type="submit"
|
|
:label="t('login.signIn')"
|
|
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 text-muted">{{ t('login.or') }}</span>
|
|
<div class="h-px flex-1 bg-default" />
|
|
</div>
|
|
|
|
<UButton
|
|
:label="t('login.passkey')"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xl"
|
|
class="w-full justify-center"
|
|
icon="i-lucide-fingerprint"
|
|
:loading="passkeyPending"
|
|
@click="loginWithPasskey"
|
|
/>
|
|
</UCard>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
|
|
|
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
|
|
|
|
import { getErrorMessage } from '../../utils/errors'
|
|
|
|
definePageMeta({
|
|
middleware: 'guest'
|
|
})
|
|
|
|
useSeoMeta({
|
|
title: 'Staff Login',
|
|
description: 'Staff login for the dinner ticket management system.',
|
|
robots: 'noindex,nofollow'
|
|
})
|
|
|
|
const toast = useToast()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuth()
|
|
const apiClient = useApiClient()
|
|
const { t } = useLocale()
|
|
|
|
const form = reactive({
|
|
username: '',
|
|
password: '',
|
|
remember: true
|
|
})
|
|
const passwordPending = ref(false)
|
|
const passkeyPending = ref(false)
|
|
|
|
function getSafeRedirectPath(value: unknown) {
|
|
const redirect = Array.isArray(value) ? value[0] : value
|
|
|
|
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
|
|
? redirect
|
|
: null
|
|
}
|
|
|
|
function validateLogin(state: typeof form): FormError[] {
|
|
const errors: FormError[] = []
|
|
|
|
if (!state.username.trim()) {
|
|
errors.push({ name: 'username', message: t('login.usernameRequired') })
|
|
}
|
|
|
|
if (!state.password.trim()) {
|
|
errors.push({ name: 'password', message: t('login.passwordRequired') })
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>) {
|
|
if (!user) {
|
|
return
|
|
}
|
|
|
|
await router.push(getSafeRedirectPath(route.query.redirect) || getDefaultAuthenticatedPath(user))
|
|
}
|
|
|
|
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: t('login.failed'),
|
|
description: getErrorMessage(error, t('login.failedDescription')),
|
|
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: t('login.passkeyFailed'),
|
|
description: getErrorMessage(error, t('login.passkeyFailedDescription')),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
passkeyPending.value = false
|
|
}
|
|
}
|
|
</script>
|