All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 38s
313 lines
8.3 KiB
Vue
313 lines
8.3 KiB
Vue
<template>
|
|
<div class="layout">
|
|
<div class="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': password && (!isPasswordStrongRef || !passwordsMatch) }"
|
|
required
|
|
/>
|
|
<small v-if="password && !isPasswordStrongRef" 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': confirmPassword && !passwordsMatch }"
|
|
required
|
|
/>
|
|
<small v-if="confirmPassword && !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 actions" style="margin-top: 0.4rem">
|
|
<button type="submit" class="btn btn-primary" :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>
|
|
|
|
<!-- Success Modal -->
|
|
<ModalDialog v-if="showModal" title="Password Reset Successful" @backdrop-click="closeModal">
|
|
<p class="modal-message">
|
|
Your password has been reset successfully. You can now sign in with your new password.
|
|
</p>
|
|
<div class="modal-actions">
|
|
<button @click="goToLogin" class="btn btn-primary">Sign In</button>
|
|
</div>
|
|
</ModalDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { isPasswordStrong } from '@/common/api'
|
|
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
|
import '@/assets/styles.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 showModal = 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/auth/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
|
|
// Redirect to AuthLanding
|
|
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
|
|
}
|
|
} catch {
|
|
errorMsg.value = 'Network error. Please try again.'
|
|
tokenValid.value = false
|
|
tokenChecked.value = true
|
|
// Redirect to AuthLanding
|
|
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
|
|
}
|
|
} else {
|
|
errorMsg.value = 'No reset token provided.'
|
|
tokenValid.value = false
|
|
tokenChecked.value = true
|
|
// Redirect to AuthLanding
|
|
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
|
|
}
|
|
})
|
|
|
|
async function submitForm() {
|
|
submitAttempted.value = true
|
|
errorMsg.value = ''
|
|
successMsg.value = ''
|
|
|
|
if (!formValid.value) return
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('/api/auth/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
|
|
}
|
|
// Success: Show modal instead of successMsg
|
|
showModal.value = true
|
|
password.value = ''
|
|
confirmPassword.value = ''
|
|
submitAttempted.value = false
|
|
} catch {
|
|
errorMsg.value = 'Network error. Please try again.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
}
|
|
|
|
async function goToLogin() {
|
|
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
|
|
}
|
|
</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);
|
|
}
|
|
|
|
.reset-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) {
|
|
.reset-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>
|