Files
dticket.tootaio.com/app/pages/login/index.vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

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>