diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue index 709091f..1296d51 100644 --- a/frontend/website/src/components/AuthForm.vue +++ b/frontend/website/src/components/AuthForm.vue @@ -1,16 +1,34 @@ @@ -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; diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue index e9ec98c..3e5b087 100644 --- a/frontend/website/src/components/AuthTextField.vue +++ b/frontend/website/src/components/AuthTextField.vue @@ -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) + } +} @@ -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; diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index 399a301..6460027 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -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 +export type LoginFieldErrors = Partial> +export type LoginResult = + | { + success: true + } + | { + success: false + fieldErrors: LoginFieldErrors + } export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -57,11 +77,90 @@ export const useAuthStore = defineStore('auth', () => { } } + async function login(email: string, password: string): Promise { + 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>, +): 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 +} diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue index e4215a4..2045c38 100644 --- a/frontend/website/src/views/LoginView.vue +++ b/frontend/website/src/views/LoginView.vue @@ -1,7 +1,60 @@