Configure default head meta and title template in nuxt.config.ts Add dynamic SEO meta tags and robots directives to all pages
245 lines
6.8 KiB
Vue
245 lines
6.8 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 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 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(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>
|