added password reset

This commit is contained in:
2026-01-07 15:16:17 -05:00
parent fd1057662f
commit 5b0fe2adc2
6 changed files with 602 additions and 1 deletions

View File

@@ -0,0 +1,187 @@
<template>
<div class="layout">
<div class="edit-view">
<form class="forgot-form" @submit.prevent="submitForm" novalidate>
<h2>Reset your password</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 v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ errorMsg }}
</div>
<div
v-if="successMsg"
class="success-message"
style="margin-bottom: 1rem"
aria-live="polite"
>
{{ successMsg }}
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="loading || !isEmailValid">
{{ loading ? 'Sending…' : 'Send reset link' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Remembered your password?
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { isEmailValid } from '@/common/api'
import '@/assets/view-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const router = useRouter()
const email = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!isEmailValidRef.value) return
loading.value = true
try {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }),
})
if (!res.ok) {
let msg = 'Could not send reset email.'
try {
const data = await res.json()
if (data && (data.error || data.message)) {
msg = data.error || data.message
}
} catch {
try {
const text = await res.text()
if (text) msg = text
} catch {}
}
errorMsg.value = msg
return
}
successMsg.value =
'If this email is registered, you will receive a password reset link shortly.'
email.value = ''
submitAttempted.value = false
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.forgot-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.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'] {
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;
}
.btn-link:disabled {
text-decoration: none;
cursor: default;
opacity: 0.75;
}
@media (max-width: 520px) {
.forgot-form {
padding: 1rem;
border-radius: 10px;
}
}
</style>

View File

@@ -105,6 +105,32 @@
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>
@@ -243,6 +269,12 @@ async function resendVerification() {
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 File

@@ -0,0 +1,286 @@
<template>
<div class="layout">
<div class="edit-view">
<form
v-if="tokenChecked && tokenValid"
class="reset-form"
@submit.prevent="submitForm"
novalidate
>
<h2>Set a new password</h2>
<div class="form-group">
<label for="password">New password</label>
<input
id="password"
type="password"
autocomplete="new-password"
v-model="password"
:class="{ 'input-error': submitAttempted && !isPasswordStrong }"
required
/>
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite">
Password is required.
</small>
<small
v-else-if="submitAttempted && !isPasswordStrong"
class="error-message"
aria-live="polite"
>
Password must be at least 8 characters and contain a letter and a number.
</small>
</div>
<div class="form-group">
<label for="confirm">Confirm password</label>
<input
id="confirm"
type="password"
autocomplete="new-password"
v-model="confirmPassword"
:class="{ 'input-error': submitAttempted && !passwordsMatch }"
required
/>
<small
v-if="submitAttempted && !confirmPassword"
class="error-message"
aria-live="polite"
>
Please confirm your password.
</small>
<small
v-else-if="submitAttempted && !passwordsMatch"
class="error-message"
aria-live="polite"
>
Passwords do not match.
</small>
</div>
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ errorMsg }}
</div>
<div
v-if="successMsg"
class="success-message"
style="margin-bottom: 1rem"
aria-live="polite"
>
{{ successMsg }}
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="loading || !formValid">
{{ loading ? 'Resetting…' : 'Reset password' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Remembered your password?
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</p>
</form>
<div
v-else-if="tokenChecked && !tokenValid"
class="error-message"
aria-live="polite"
style="margin-top: 2rem"
>
{{ errorMsg }}
<div style="margin-top: 1.2rem">
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</div>
</div>
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api'
import '@/assets/view-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const router = useRouter()
const route = useRoute()
const password = ref('')
const confirmPassword = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
const token = ref('')
const tokenValid = ref(false)
const tokenChecked = ref(false)
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
const formValid = computed(
() =>
password.value && confirmPassword.value && isPasswordStrongRef.value && passwordsMatch.value,
)
onMounted(async () => {
// Get token from query string
const raw = route.query.token ?? ''
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
// Validate token with backend
if (token.value) {
try {
const res = await fetch(`/api/validate-reset-token?token=${encodeURIComponent(token.value)}`)
tokenChecked.value = true
if (res.ok) {
tokenValid.value = true
} else {
const data = await res.json().catch(() => ({}))
errorMsg.value = data.error || 'This password reset link is invalid or has expired.'
tokenValid.value = false
}
} catch {
errorMsg.value = 'Network error. Please try again.'
tokenValid.value = false
tokenChecked.value = true
}
} else {
errorMsg.value = 'No reset token provided.'
tokenValid.value = false
tokenChecked.value = true
}
})
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!formValid.value) return
loading.value = true
try {
const res = await fetch('/api/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token.value,
password: password.value,
}),
})
if (!res.ok) {
let msg = 'Could not reset password.'
try {
const data = await res.json()
if (data && (data.error || data.message)) {
msg = data.error || data.message
}
} catch {
try {
const text = await res.text()
if (text) msg = text
} catch {}
}
errorMsg.value = msg
return
}
successMsg.value = 'Your password has been reset. You may now sign in.'
password.value = ''
confirmPassword.value = ''
submitAttempted.value = false // <-- add this line
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.reset-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.form-group label {
display: block;
margin-bottom: 0.45rem;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.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;
}
.btn-link:disabled {
text-decoration: none;
cursor: default;
opacity: 0.75;
}
@media (max-width: 520px) {
.reset-form {
padding: 1rem;
border-radius: 10px;
}
}
</style>

View File

@@ -29,7 +29,10 @@ const handleBack = () => {
}
// hide back button specifically on the Auth landing route
const showBack = computed(() => route.name !== 'AuthLanding' && route.name !== 'VerifySignup')
const showBack = computed(
() =>
route.name !== 'AuthLanding' && route.name !== 'VerifySignup' && route.name !== 'ResetPassword',
)
</script>
<style scoped>

View File

@@ -44,6 +44,16 @@ const routes = [
name: 'VerifySignup',
component: () => import('@/components/auth/VerifySignup.vue'),
},
{
path: 'forgot-password',
name: 'ForgotPassword',
component: () => import('@/components/auth/ForgotPassword.vue'),
},
{
path: 'reset-password',
name: 'ResetPassword',
component: () => import('@/components/auth/ResetPassword.vue'),
},
],
},
{