Added beginning of login functionality

This commit is contained in:
2026-01-05 15:08:29 -05:00
parent 3b7798369f
commit 46af0fb959
18 changed files with 1402 additions and 1 deletions

View File

@@ -0,0 +1,369 @@
<template>
<div class="layout">
<div class="edit-view">
<form
v-if="!signupSuccess"
@submit.prevent="submitForm"
class="signup-form child-edit-view"
novalidate
>
<h2>Sign up</h2>
<div class="form-group">
<label for="firstName">First name</label>
<input
v-model="firstName"
id="firstName"
type="text"
autofocus
autocomplete="given-name"
required
:class="{ 'input-error': submitAttempted && !firstName }"
/>
<small v-if="submitAttempted && !firstName" class="error-message" aria-live="polite"
>First name is required.</small
>
</div>
<div class="form-group">
<label for="lastName">Last name</label>
<input
v-model="lastName"
id="lastName"
autocomplete="family-name"
type="text"
required
:class="{ 'input-error': submitAttempted && !lastName }"
/>
<small v-if="submitAttempted && !lastName" class="error-message" aria-live="polite"
>Last name is required.</small
>
</div>
<div class="form-group">
<label for="email">Email address</label>
<input
v-model="email"
id="email"
autocomplete="email"
type="email"
required
:class="{ 'input-error': submitAttempted && (!email || !isEmailValid) }"
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
>Email is required.</small
>
<small v-else-if="submitAttempted && !isEmailValid" class="error-message"
>Please enter a valid email address.</small
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="password"
id="password"
autocomplete="new-password"
type="password"
required
@input="checkPasswordStrength"
:class="{ 'input-error': (submitAttempted || passwordTouched) && !isPasswordStrong }"
/>
<small
v-if="(submitAttempted || passwordTouched) && !isPasswordStrong"
class="error-message"
aria-live="polite"
>Password must be at least 8 characters, include a number and a letter.</small
>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm password</label>
<input
v-model="confirmPassword"
id="confirmPassword"
autocomplete="new-password"
type="password"
required
:class="{ 'input-error': (submitAttempted || confirmTouched) && !passwordsMatch }"
@blur="confirmTouched = true"
/>
<small
v-if="(submitAttempted || confirmTouched) && !passwordsMatch"
class="error-message"
aria-live="polite"
>Passwords do not match.</small
>
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="!formValid || loading">Sign up</button>
</div>
</form>
<!-- Error and success messages -->
<ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" />
<!-- Modal for "Account already exists" -->
<ModalDialog v-if="showEmailExistsModal">
<h3>Account already exists</h3>
<p>
An account with <strong>{{ email }}</strong> already exists.
</p>
<div style="display: flex; gap: 2rem; justify-content: center">
<button @click="goToLogin" class="form-btn">Sign In</button>
<button @click="showEmailExistsModal = false" class="form-btn">Cancel</button>
</div>
</ModalDialog>
<!-- Verification card shown after successful signup -->
<div v-else-if="signupSuccess">
<div class="icon-wrap" aria-hidden="true">
<!-- simple check icon -->
<svg
class="success-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<h2 class="card-title">Check your email</h2>
<p class="card-message">
A verification link has been sent to <strong>{{ email }}</strong
>. Please open the email and follow the instructions to verify your account.
</p>
<div class="card-actions">
<button class="form-btn" @click="goToLogin">Go to Sign In</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ErrorMessage from '@/ErrorMessage.vue'
import ModalDialog from '@/ModalDialog.vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { parseErrorResponse, isEmailValid, isPasswordStrong } from '@/common/api'
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
import '@/assets/view-shared.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
const router = useRouter()
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const passwordTouched = ref(false)
const confirmTouched = ref(false)
const submitAttempted = ref(false)
const signupError = ref('')
const signupSuccess = ref(false)
const showEmailExistsModal = ref(false)
const loading = ref(false)
function checkPasswordStrength() {
passwordTouched.value = true
}
const isEmailValidRef = computed(() => isEmailValid(email.value))
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
const formValid = computed(
() =>
firstName.value &&
lastName.value &&
email.value &&
isEmailValidRef.value &&
isPasswordStrongRef.value &&
passwordsMatch.value,
)
async function submitForm() {
submitAttempted.value = true
passwordTouched.value = true
confirmTouched.value = true
signupError.value = ''
signupSuccess.value = false
showEmailExistsModal.value = false
if (!formValid.value) return
try {
loading.value = true
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName.value.trim(),
last_name: lastName.value.trim(),
email: email.value.trim(),
password: password.value,
}),
})
if (!response.ok) {
const { msg, code } = await parseErrorResponse(response)
let displayMsg = msg
switch (code) {
case MISSING_FIELDS:
displayMsg = 'Please fill in all required fields.'
clearFields()
break
case EMAIL_EXISTS:
displayMsg = 'An account with this email already exists.'
showEmailExistsModal.value = true
break
default:
break
}
signupError.value = displayMsg
return
}
// Signup successful
signupSuccess.value = true
clearFields()
} catch (err) {
signupError.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
function goToLogin() {
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
// Clear password fields and close modal
function handleCancelEmailExists() {
password.value = ''
confirmPassword.value = ''
showEmailExistsModal.value = false
}
async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
try {
const data = await res.json()
return { msg: data.error || data.message || 'Signup failed.', code: data.code }
} catch {
const text = await res.text()
return { msg: text || 'Signup failed.' }
}
}
function clearFields() {
firstName.value = ''
lastName.value = ''
email.value = ''
password.value = ''
confirmPassword.value = ''
passwordTouched.value = false
confirmTouched.value = false
submitAttempted.value = false
signupError.value = ''
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.signup-form {
/* keep the edit-view / child-edit-view look from edit-forms.css,
only adjust inputs for email/password types */
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
margin: 0 auto 1rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--btn-green, #22c55e), var(--btn-green-hover, #16a34a));
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.12);
}
.success-icon {
width: 36px;
height: 36px;
stroke: #fff;
}
.card-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--card-title, #333);
}
.card-message {
margin-top: 0.6rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.98rem;
line-height: 1.4;
}
.card-actions {
margin-top: 1.2rem;
display: flex;
gap: 0.6rem;
justify-content: center;
}
/* Reuse existing input / label 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;
}
/* Modal styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #fff;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px;
text-align: center;
}
</style>