Files
dticket.tootaio.com/app/pages/login/index.vue
xiaomai cb683d6b3d feat(bookings): restrict management to assigned PIC or super admin
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
2026-05-09 13:28:50 +08:00

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>