Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
239 lines
6.7 KiB
Vue
239 lines
6.7 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'
|
|
})
|
|
|
|
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>
|