Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
195 lines
5.1 KiB
Vue
195 lines
5.1 KiB
Vue
<template>
|
|
<UContainer class="py-10 lg:py-16">
|
|
<div class="mx-auto max-w-md">
|
|
<UCard class="border border-default bg-default shadow-sm">
|
|
<template #header>
|
|
<div class="space-y-2">
|
|
<UBadge :label="t('login.badge')" color="primary" variant="soft" class="rounded-full" />
|
|
<h1 class="text-3xl font-bold text-highlighted">
|
|
{{ t('login.title') }}
|
|
</h1>
|
|
</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 tracking-[0.2em] text-muted">{{ t('login.or') }}</span>
|
|
<div class="h-px flex-1 bg-default" />
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<UButton
|
|
:label="t('login.passkey')"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xl"
|
|
class="w-full justify-center"
|
|
icon="i-lucide-fingerprint"
|
|
:loading="passkeyPending"
|
|
@click="loginWithPasskey"
|
|
/>
|
|
</div>
|
|
</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'
|
|
})
|
|
|
|
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>
|