feat(auth): 密码重置邮件队列化与限流优化
- 邮件发送改为异步队列处理,避免并发导致发送失败 - 新增 Email 维度限流(30秒冷却期),防止邮件轰炸 - Token 验证使用常量时间比较,防止时序攻击 - 重构代码消除冗余,提取公共验证逻辑
This commit is contained in:
@@ -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,
|
||||
|
||||
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal file
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
355
frontend/src/views/auth/ResetPasswordView.vue
Normal file
355
frontend/src/views/auth/ResetPasswordView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user