This commit is contained in:
326
frontend/vue-app/src/components/auth/Login.vue
Normal file
326
frontend/vue-app/src/components/auth/Login.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="edit-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
|
||||
/>
|
||||
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite"
|
||||
>Password is required.</small
|
||||
>
|
||||
</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" style="margin-top: 0.4rem">
|
||||
<button type="submit" class="form-btn" :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/view-shared.css'
|
||||
import '@/assets/global.css'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.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
|
||||
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.'
|
||||
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
|
||||
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>
|
||||
:deep(.edit-view) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* reuse edit-forms form-group styles */
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
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;
|
||||
}
|
||||
|
||||
/* also ensure disabled button doesn't show underline in browsers that style disabled anchors/buttons */
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login-form {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user