Files
chore/frontend/vue-app/src/components/auth/Login.vue
Ryan Kegel 47541afbbf
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 46s
Add unit tests for LoginButton component with comprehensive coverage
2026-02-05 16:37:10 -05:00

343 lines
9.1 KiB
Vue

<template>
<div class="layout">
<div class="view">
<form class="login-form" @submit.prevent="submitForm" novalidate>
<h2>Sign in</h2>
<div class="form-group">
<label for="email">Email address</label>
<input
id="email"
type="email"
autocomplete="username"
autofocus
v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }"
required
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
>Email is required.</small
>
<small
v-else-if="submitAttempted && !isEmailValid"
class="error-message"
aria-live="polite"
>Please enter a valid email address.</small
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
autocomplete="current-password"
v-model="password"
:class="{ 'input-error': submitAttempted && !password }"
required
/>
</div>
<!-- show server error message -->
<div v-if="loginError" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ loginError }}
</div>
<!-- show resend UI when server indicated unverified account (independent of loginError) -->
<div v-if="showResend && !resendSent" style="margin-top: 0.5rem">
<button
v-if="!resendLoading"
type="button"
class="btn-link"
@click="resendVerification"
:disabled="!email"
>
Resend verification email
</button>
<span v-else class="btn-link btn-disabled" aria-busy="true">Sending</span>
</div>
<!-- success / error messages for the resend action (shown even if loginError was cleared) -->
<div
v-if="resendSent"
style="margin-top: 0.5rem; color: var(--success, #16a34a); font-size: 0.92rem"
>
Verification email sent. Check your inbox.
</div>
<div v-if="resendError" class="error-message" style="margin-top: 0.5rem" aria-live="polite">
{{ resendError }}
</div>
<div class="form-group actions" style="margin-top: 0.4rem">
<button type="submit" class="btn btn-primary" :disabled="loading || !formValid">
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Don't have an account?
<button
type="button"
class="btn-link"
@click="goToSignup"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign up
</button>
</p>
<p
style="
text-align: center;
margin-top: 0.4rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Forgot your password?
<button
type="button"
class="btn-link"
@click="goToForgotPassword"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Reset password
</button>
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import '@/assets/styles.css'
import {
MISSING_EMAIL_OR_PASSWORD,
INVALID_CREDENTIALS,
NOT_VERIFIED,
MISSING_EMAIL,
USER_NOT_FOUND,
ALREADY_VERIFIED,
} from '@/common/errorCodes'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { loginUser } from '@/stores/auth'
const router = useRouter()
const email = ref('')
const password = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const loginError = ref('')
/* new state for resend flow */
const showResend = ref(false)
const resendLoading = ref(false)
const resendSent = ref(false)
const resendError = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
const formValid = computed(() => email.value && isEmailValidRef.value && password.value)
async function submitForm() {
submitAttempted.value = true
loginError.value = ''
showResend.value = false
resendError.value = ''
resendSent.value = false
if (!formValid.value) return
if (loading.value) return
loading.value = true
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim(), password: password.value }),
})
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
showResend.value = false
let displayMsg = msg
let shouldClearPassword = false
switch (code) {
case MISSING_EMAIL_OR_PASSWORD:
displayMsg = 'Email and password are required.'
break
case INVALID_CREDENTIALS:
displayMsg = 'The email and password combination is incorrect. Please try again.'
shouldClearPassword = true
break
case NOT_VERIFIED:
displayMsg =
'Your account is not verified. Please check your email for the verification link.'
showResend.value = true
break
default:
displayMsg = msg || `Login failed with status ${res.status}.`
}
loginError.value = displayMsg
if (shouldClearPassword) {
password.value = ''
}
return
}
loginUser() // <-- set user as logged in
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
} catch (err) {
loginError.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function resendVerification() {
loginError.value = ''
resendError.value = ''
resendSent.value = false
if (!email.value) {
resendError.value = 'Please enter your email above to resend verification.'
return
}
resendLoading.value = true
try {
const res = await fetch('/api/resend-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
resendError.value = msg
let displayMsg = msg
switch (code) {
case MISSING_EMAIL:
displayMsg = 'Email is required.'
break
case USER_NOT_FOUND:
displayMsg = 'This email is not registered.'
break
case ALREADY_VERIFIED:
displayMsg = 'Your account is already verified. Please log in.'
showResend.value = false
break
default:
displayMsg = msg || `Login failed with status ${res.status}.`
}
resendError.value = displayMsg
return
}
resendSent.value = true
} catch {
resendError.value = 'Network error. Please try again.'
} finally {
resendLoading.value = false
}
}
async function goToSignup() {
await router.push({ name: 'Signup' }).catch(() => (window.location.href = '/auth/signup'))
}
async function goToForgotPassword() {
await router
.push({ name: 'ForgotPassword' })
.catch(() => (window.location.href = '/auth/forgot-password'))
}
</script>
<style scoped>
.view {
max-width: 420px;
margin: 0 auto;
background: var(--edit-view-bg, #fff);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
}
.view h2 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--form-heading);
}
.login-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
/* reuse edit-forms form-group styles */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.form-group input[type='email'],
.form-group input[type='password'] {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #fff);
box-sizing: border-box;
}
@media (max-width: 520px) {
.login-form {
padding: 1rem;
border-radius: 10px;
}
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 0.4rem;
}
.actions .btn {
padding: 1rem 2.2rem;
font-weight: 700;
font-size: 1.25rem;
min-width: 120px;
}
</style>