feat(auth): 密码重置邮件队列化与限流优化

- 邮件发送改为异步队列处理,避免并发导致发送失败
- 新增 Email 维度限流(30秒冷却期),防止邮件轰炸
- Token 验证使用常量时间比较,防止时序攻击
- 重构代码消除冗余,提取公共验证逻辑
This commit is contained in:
shaw
2026-01-24 22:33:45 +08:00
parent 43a1031e38
commit 9cc8352593
25 changed files with 1497 additions and 114 deletions

View File

@@ -13,6 +13,7 @@ export interface SystemSettings {
registration_enabled: boolean
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
// Default settings
default_balance: number
default_concurrency: number
@@ -66,6 +67,7 @@ export interface UpdateSettingsRequest {
registration_enabled?: boolean
email_verify_enabled?: boolean
promo_code_enabled?: boolean
password_reset_enabled?: boolean
default_balance?: number
default_concurrency?: number
site_name?: string

View File

@@ -133,6 +133,57 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
return data
}
/**
* Forgot password request
*/
export interface ForgotPasswordRequest {
email: string
turnstile_token?: string
}
/**
* Forgot password response
*/
export interface ForgotPasswordResponse {
message: string
}
/**
* Request password reset link
* @param request - Email and optional Turnstile token
* @returns Response with message
*/
export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> {
const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request)
return data
}
/**
* Reset password request
*/
export interface ResetPasswordRequest {
email: string
token: string
new_password: string
}
/**
* Reset password response
*/
export interface ResetPasswordResponse {
message: string
}
/**
* Reset password with token
* @param request - Email, token, and new password
* @returns Response with message
*/
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
return data
}
export const authAPI = {
login,
register,
@@ -144,7 +195,9 @@ export const authAPI = {
clearAuthToken,
getPublicSettings,
sendVerifyCode,
validatePromoCode
validatePromoCode,
forgotPassword,
resetPassword
}
export default authAPI

View File

@@ -271,7 +271,36 @@ export default {
code: 'Code',
state: 'State',
fullUrl: 'Full URL'
}
},
// Forgot password
forgotPassword: 'Forgot password?',
forgotPasswordTitle: 'Reset Your Password',
forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.',
sendResetLink: 'Send Reset Link',
sendingResetLink: 'Sending...',
sendResetLinkFailed: 'Failed to send reset link. Please try again.',
resetEmailSent: 'Reset Link Sent',
resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.',
backToLogin: 'Back to Login',
rememberedPassword: 'Remembered your password?',
// Reset password
resetPasswordTitle: 'Set New Password',
resetPasswordHint: 'Enter your new password below.',
newPassword: 'New Password',
newPasswordPlaceholder: 'Enter your new password',
confirmPassword: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm your new password',
confirmPasswordRequired: 'Please confirm your password',
passwordsDoNotMatch: 'Passwords do not match',
resetPassword: 'Reset Password',
resettingPassword: 'Resetting...',
resetPasswordFailed: 'Failed to reset password. Please try again.',
passwordResetSuccess: 'Password Reset Successful',
passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.',
invalidResetLink: 'Invalid Reset Link',
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
requestNewResetLink: 'Request New Reset Link',
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
},
// Dashboard
@@ -2743,7 +2772,9 @@ export default {
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration'
promoCodeHint: 'Allow users to use promo codes during registration',
passwordReset: 'Password Reset',
passwordResetHint: 'Allow users to reset their password via email'
},
turnstile: {
title: 'Cloudflare Turnstile',

View File

@@ -268,7 +268,36 @@ export default {
code: '授权码',
state: '状态',
fullUrl: '完整URL'
}
},
// 忘记密码
forgotPassword: '忘记密码?',
forgotPasswordTitle: '重置密码',
forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。',
sendResetLink: '发送重置链接',
sendingResetLink: '发送中...',
sendResetLinkFailed: '发送重置链接失败,请重试。',
resetEmailSent: '重置链接已发送',
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
backToLogin: '返回登录',
rememberedPassword: '想起密码了?',
// 重置密码
resetPasswordTitle: '设置新密码',
resetPasswordHint: '请在下方输入您的新密码。',
newPassword: '新密码',
newPasswordPlaceholder: '输入新密码',
confirmPassword: '确认密码',
confirmPasswordPlaceholder: '再次输入新密码',
confirmPasswordRequired: '请确认您的密码',
passwordsDoNotMatch: '两次输入的密码不一致',
resetPassword: '重置密码',
resettingPassword: '重置中...',
resetPasswordFailed: '重置密码失败,请重试。',
passwordResetSuccess: '密码重置成功',
passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。',
invalidResetLink: '无效的重置链接',
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
requestNewResetLink: '请求新的重置链接',
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
},
// Dashboard
@@ -2896,7 +2925,9 @@ export default {
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码'
promoCodeHint: '允许用户在注册时使用优惠码',
passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码'
},
turnstile: {
title: 'Cloudflare Turnstile',

View File

@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
title: 'LinuxDo OAuth Callback'
}
},
{
path: '/forgot-password',
name: 'ForgotPassword',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: {
requiresAuth: false,
title: 'Forgot Password'
}
},
{
path: '/reset-password',
name: 'ResetPassword',
component: () => import('@/views/auth/ResetPasswordView.vue'),
meta: {
requiresAuth: false,
title: 'Reset Password'
}
},
// ==================== User Routes ====================
{

View File

@@ -313,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
registration_enabled: false,
email_verify_enabled: false,
promo_code_enabled: true,
password_reset_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: siteName.value,

View File

@@ -71,6 +71,7 @@ export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string

View File

@@ -338,6 +338,22 @@
</div>
<Toggle v-model="form.promo_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if="form.email_verify_enabled"
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.passwordReset')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.passwordResetHint') }}
</p>
</div>
<Toggle v-model="form.password_reset_enabled" />
</div>
</div>
</div>
@@ -1029,6 +1045,7 @@ const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
promo_code_enabled: true,
password_reset_enabled: false,
default_balance: 0,
default_concurrency: 1,
site_name: 'Sub2API',
@@ -1152,6 +1169,7 @@ async function saveSettings() {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
promo_code_enabled: form.promo_code_enabled,
password_reset_enabled: form.password_reset_enabled,
default_balance: form.default_balance,
default_concurrency: form.default_concurrency,
site_name: form.site_name,

View File

@@ -0,0 +1,297 @@
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.forgotPasswordTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.forgotPasswordHint') }}
</p>
</div>
<!-- Success State -->
<div v-if="isSubmitted" class="space-y-6">
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
{{ t('auth.resetEmailSent') }}
</h3>
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
{{ t('auth.resetEmailSentHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/login"
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="arrowLeft" size="sm" />
{{ t('auth.backToLogin') }}
</router-link>
</div>
</div>
<!-- Form State -->
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="mail" size="md" class="mr-2" />
{{ isLoading ? t('auth.sendingResetLink') : t('auth.sendResetLink') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.rememberedPassword') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAppStore } from '@/stores'
import { getPublicSettings, forgotPassword } from '@/api/auth'
const { t } = useI18n()
// ==================== Stores ====================
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSubmitted = ref<boolean>(false)
const errorMessage = ref<string>('')
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
const formData = reactive({
email: ''
})
const errors = reactive({
email: '',
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
} catch (error) {
console.error('Failed to load public settings:', error)
}
})
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
function validateForm(): boolean {
errors.email = ''
errors.turnstile = ''
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleSubmit(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
await forgotPassword({
email: formData.email,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
isSubmitted.value = true
appStore.showSuccess(t('auth.resetEmailSent'))
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset()
turnstileToken.value = ''
}
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.sendResetLinkFailed')
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -72,9 +72,19 @@
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<div class="mt-1 flex items-center justify-between">
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<span v-else></span>
<router-link
v-if="passwordResetEnabled"
to="/forgot-password"
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.forgotPassword') }}
</router-link>
</div>
</div>
<!-- Turnstile Widget -->
@@ -184,6 +194,7 @@ const showPassword = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -216,6 +227,7 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
passwordResetEnabled.value = settings.password_reset_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
}

View File

@@ -0,0 +1,355 @@
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.resetPasswordTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.resetPasswordHint') }}
</p>
</div>
<!-- Invalid Link State -->
<div v-if="isInvalidLink" class="space-y-6">
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
{{ t('auth.invalidResetLink') }}
</h3>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
{{ t('auth.invalidResetLinkHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/forgot-password"
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.requestNewResetLink') }}
</router-link>
</div>
</div>
<!-- Success State -->
<div v-else-if="isSuccess" class="space-y-6">
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
{{ t('auth.passwordResetSuccess') }}
</h3>
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
{{ t('auth.passwordResetSuccessHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/login"
class="btn btn-primary inline-flex items-center gap-2"
>
<Icon name="login" size="md" />
{{ t('auth.signIn') }}
</router-link>
</div>
</div>
<!-- Form State -->
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<!-- Email (readonly) -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
:value="email"
type="email"
readonly
disabled
class="input pl-11 bg-gray-50 dark:bg-dark-700"
/>
</div>
</div>
<!-- New Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.newPassword') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.newPasswordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
</div>
<!-- Confirm Password Input -->
<div>
<label for="confirmPassword" class="input-label">
{{ t('auth.confirmPassword') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="confirmPassword"
v-model="formData.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.confirmPassword }"
:placeholder="t('auth.confirmPasswordPlaceholder')"
/>
<button
type="button"
@click="showConfirmPassword = !showConfirmPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showConfirmPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.confirmPassword" class="input-error-text">
{{ errors.confirmPassword }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="checkCircle" size="md" class="mr-2" />
{{ isLoading ? t('auth.resettingPassword') : t('auth.resetPassword') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.rememberedPassword') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { resetPassword } from '@/api/auth'
const { t } = useI18n()
// ==================== Router & Stores ====================
const route = useRoute()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSuccess = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const showConfirmPassword = ref<boolean>(false)
// URL parameters
const email = ref<string>('')
const token = ref<string>('')
const formData = reactive({
password: '',
confirmPassword: ''
})
const errors = reactive({
password: '',
confirmPassword: ''
})
// Check if the reset link is valid (has email and token)
const isInvalidLink = computed(() => !email.value || !token.value)
// ==================== Lifecycle ====================
onMounted(() => {
// Get email and token from URL query parameters
email.value = (route.query.email as string) || ''
token.value = (route.query.token as string) || ''
})
// ==================== Validation ====================
function validateForm(): boolean {
errors.password = ''
errors.confirmPassword = ''
let isValid = true
// Password validation
if (!formData.password) {
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Confirm password validation
if (!formData.confirmPassword) {
errors.confirmPassword = t('auth.confirmPasswordRequired')
isValid = false
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = t('auth.passwordsDoNotMatch')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleSubmit(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
await resetPassword({
email: email.value,
token: token.value,
new_password: formData.password
})
isSuccess.value = true
appStore.showSuccess(t('auth.passwordResetSuccess'))
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string; code?: string } } }
// Check for invalid/expired token error
if (err.response?.data?.code === 'INVALID_RESET_TOKEN') {
errorMessage.value = t('auth.invalidOrExpiredToken')
} else if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.resetPasswordFailed')
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>