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)
+ }
+}
@@ -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"
/>
+
+ {{ error }}
+
@@ -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 @@
@@ -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."
>
-
+