wire frontend password login

This commit is contained in:
Yisroel Baum 2026-07-31 11:44:19 +03:00
parent 1dff3f6976
commit 3da9c586c3
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
4 changed files with 239 additions and 3 deletions

View file

@ -1,16 +1,34 @@
<script setup lang="ts">
defineProps<{
submitLabel: string
submittingLabel?: string
submitting?: boolean
error?: string
}>()
defineEmits<{
submit: []
}>()
</script>
<template>
<form class="auth-form" @submit.prevent>
<form
class="auth-form"
:aria-busy="submitting === true ? 'true' : undefined"
novalidate
@submit.prevent="$emit('submit')"
>
<div class="auth-form__fields">
<slot></slot>
</div>
<button type="submit">{{ submitLabel }}</button>
<p v-if="error !== undefined" class="auth-form__error" role="alert">
{{ error }}
</p>
<button type="submit" :disabled="submitting">
{{ submitting && submittingLabel ? submittingLabel : submitLabel }}
</button>
</form>
</template>
@ -60,6 +78,22 @@ button:focus-visible {
outline-offset: 0.2rem;
}
button:disabled {
border-color: #708079;
background: #708079;
box-shadow: none;
cursor: wait;
transform: none;
}
.auth-form__error {
margin: -0.65rem 0;
color: #a33f37;
font-size: 0.8rem;
font-weight: 650;
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
button {
transition: none;

View file

@ -5,7 +5,20 @@ defineProps<{
type: 'email' | 'password' | 'text'
autocomplete: string
placeholder: string
modelValue?: string
error?: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function updateValue(event: Event): void {
if (event.target instanceof HTMLInputElement) {
emit('update:modelValue', event.target.value)
}
}
</script>
<template>
@ -17,7 +30,15 @@ defineProps<{
:name="id"
:autocomplete="autocomplete"
:placeholder="placeholder"
:value="modelValue"
:disabled="disabled"
:aria-invalid="error === undefined ? undefined : 'true'"
:aria-describedby="error === undefined ? undefined : `${id}-error`"
@input="updateValue"
/>
<p v-if="error !== undefined" :id="`${id}-error`" class="text-field__error">
{{ error }}
</p>
</div>
</template>
@ -62,6 +83,23 @@ input:focus {
box-shadow: 0 0 0 3px rgb(77 125 109 / 16%);
}
input[aria-invalid='true'] {
border-color: #a54e46;
}
input:disabled {
color: #67736e;
background: #f5f5f2;
cursor: not-allowed;
}
.text-field__error {
margin: 0;
color: #a33f37;
font-size: 0.76rem;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
input {
transition: none;

View file

@ -13,7 +13,27 @@ const meResponseSchema = z.object({
user: authUserSchema,
})
const invalidCredentialsResponseSchema = z.object({
error: z.literal('invalid_credentials'),
})
const loginValidationResponseSchema = z.object({
errors: z.object({
email: z.array(z.string()).optional(),
password: z.array(z.string()).optional(),
}),
})
export type AuthUser = z.infer<typeof authUserSchema>
export type LoginFieldErrors = Partial<Record<'email' | 'password', string>>
export type LoginResult =
| {
success: true
}
| {
success: false
fieldErrors: LoginFieldErrors
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null)
@ -57,11 +77,90 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function login(email: string, password: string): Promise<LoginResult> {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_BASE}/api/login`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const responseBody: unknown = await response.json()
if (response.status === 200) {
user.value = meResponseSchema.parse(responseBody).user
return { success: true }
}
user.value = null
if (
response.status === 401 &&
invalidCredentialsResponseSchema.safeParse(responseBody).success
) {
error.value = 'Email or password is incorrect.'
return { success: false, fieldErrors: {} }
}
if (response.status === 422) {
const validationResponse = loginValidationResponseSchema.safeParse(responseBody)
if (validationResponse.success) {
return {
success: false,
fieldErrors: firstLoginFieldErrors(validationResponse.data.errors),
}
}
}
if (response.status === 429) {
error.value = 'Too many login attempts. Try again in a minute.'
return { success: false, fieldErrors: {} }
}
error.value = 'Unable to log in. Please try again.'
return { success: false, fieldErrors: {} }
} catch {
user.value = null
error.value = 'Unable to log in. Please try again.'
return { success: false, fieldErrors: {} }
} finally {
loading.value = false
}
}
return {
user,
loading,
error,
isAuthenticated,
fetchMe,
login,
}
})
function firstLoginFieldErrors(
errors: Partial<Record<'email' | 'password', string[]>>,
): LoginFieldErrors {
const fieldErrors: LoginFieldErrors = {}
const emailError = errors.email?.[0]
const passwordError = errors.password?.[0]
if (emailError !== undefined) {
fieldErrors.email = emailError
}
if (passwordError !== undefined) {
fieldErrors.password = passwordError
}
return fieldErrors
}

View file

@ -1,7 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { z } from 'zod'
import AuthForm from '@/components/AuthForm.vue'
import AuthLayout from '@/components/AuthLayout.vue'
import AuthTextField from '@/components/AuthTextField.vue'
import { type LoginFieldErrors, useAuthStore } from '@/stores/auth'
const emailSchema = z.string().email()
const email = ref('')
const password = ref('')
const fieldErrors = ref<LoginFieldErrors>({})
const authStore = useAuthStore()
const { loading, error } = storeToRefs(authStore)
const route = useRoute()
const router = useRouter()
async function submitLogin(): Promise<void> {
const normalizedEmail = email.value.trim()
fieldErrors.value = validateLogin(normalizedEmail, password.value)
if (Object.keys(fieldErrors.value).length > 0) {
return
}
const result = await authStore.login(normalizedEmail, password.value)
if (!result.success) {
fieldErrors.value = result.fieldErrors
return
}
await router.push(safeLoginRedirect(route.query.redirect))
}
function validateLogin(submittedEmail: string, submittedPassword: string): LoginFieldErrors {
const errors: LoginFieldErrors = {}
if (!emailSchema.safeParse(submittedEmail).success) {
errors.email = 'Enter a valid email address.'
}
if (submittedPassword === '') {
errors.password = 'Enter your password.'
}
return errors
}
function safeLoginRedirect(redirect: unknown): string {
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect
}
return '/dashboard'
}
</script>
<template>
@ -10,13 +63,22 @@ import AuthTextField from '@/components/AuthTextField.vue'
title="Welcome back"
description="Pick up where you left off and keep your momentum going."
>
<AuthForm submit-label="Log in">
<AuthForm
submit-label="Log in"
submitting-label="Logging in..."
:submitting="loading"
:error="error ?? undefined"
@submit="submitLogin"
>
<AuthTextField
id="login-email"
label="Email address"
type="email"
autocomplete="email"
placeholder="you@example.com"
v-model="email"
:error="fieldErrors.email"
:disabled="loading"
/>
<AuthTextField
id="login-password"
@ -24,6 +86,9 @@ import AuthTextField from '@/components/AuthTextField.vue'
type="password"
autocomplete="current-password"
placeholder="Enter your password"
v-model="password"
:error="fieldErrors.password"
:disabled="loading"
/>
</AuthForm>