Files
Aether/frontend/src/features/auth/components/RegisterDialog.vue
fawney19 cddc22d2b3 refactor: 重构邮箱验证模块并修复代码审查问题
- 重构: 将 verification 模块重命名为 email,目录结构更清晰
- 新增: 独立的邮件配置管理页面 (EmailSettings.vue)
- 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览)
- 新增: 查询验证状态 API,支持页面刷新后恢复验证流程
- 新增: 注册邮箱后缀白名单/黑名单限制功能
- 修复: 统一密码最小长度为 6 位(前后端一致)
- 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起
- 修复: 邮件模板变量添加 HTML 转义,防止 XSS
- 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件
- 优化: RegisterDialog 重写验证码输入组件,提升用户体验
- 优化: Input 组件支持 disableAutofill 属性
2026-01-01 02:10:19 +08:00

641 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Dialog
v-model:open="isOpen"
size="lg"
>
<div class="space-y-6">
<!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center">
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
<img
src="/aether_adaptive.svg"
alt="Logo"
class="h-16 w-16"
>
</div>
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
注册新账户
</h2>
<p class="mt-1 text-sm text-muted-foreground">
请填写您的邮箱和个人信息完成注册
</p>
</div>
<!-- 注册表单 -->
<form
class="space-y-4"
autocomplete="off"
data-form-type="other"
@submit.prevent="handleSubmit"
>
<!-- Email -->
<div class="space-y-2">
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
<Input
id="reg-email"
v-model="formData.email"
type="email"
placeholder="hello@example.com"
required
disable-autofill
:disabled="isLoading || emailVerified"
/>
</div>
<!-- Verification Code Section -->
<div
v-if="requireEmailVerification"
class="space-y-3"
>
<div class="flex items-center justify-between">
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
<Button
type="button"
variant="link"
size="sm"
class="h-auto p-0 text-xs"
:disabled="isSendingCode || !canSendCode || emailVerified"
@click="handleSendCode"
>
{{ sendCodeButtonText }}
</Button>
</div>
<div class="flex justify-center gap-2">
<!-- 发送中显示 loading -->
<div
v-if="isSendingCode"
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
>
<svg
class="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<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"
/>
</svg>
<span class="text-sm">正在发送验证码...</span>
</div>
<!-- 验证码输入框 -->
<template v-else>
<input
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
v-model="codeDigits[index]"
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="off"
data-form-type="other"
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
:disabled="emailVerified"
@input="handleCodeInput(index, $event)"
@keydown="handleCodeKeyDown(index, $event)"
@paste="handleCodePaste"
>
</template>
</div>
</div>
<!-- Username -->
<div class="space-y-2">
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
<Input
id="reg-uname"
v-model="formData.username"
type="text"
placeholder="请输入用户名"
required
disable-autofill
:disabled="isLoading"
/>
</div>
<!-- Password -->
<div class="space-y-2">
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
<Input
:id="`pwd-${formNonce}`"
v-model="formData.password"
type="text"
autocomplete="one-time-code"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
:name="`pwd-${formNonce}`"
placeholder="至少 6 个字符"
required
class="-webkit-text-security-disc"
:disabled="isLoading"
/>
</div>
<!-- Confirm Password -->
<div class="space-y-2">
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="formData.confirmPassword"
type="text"
autocomplete="one-time-code"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
:name="`pwd-confirm-${formNonce}`"
placeholder="再次输入密码"
required
class="-webkit-text-security-disc"
:disabled="isLoading"
/>
</div>
</form>
<!-- 登录链接 -->
<div class="text-center text-sm">
已有账户
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToLogin"
>
立即登录
</Button>
</div>
</div>
<template #footer>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
:disabled="isLoading"
@click="handleCancel"
>
取消
</Button>
<Button
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
:disabled="isLoading || !canSubmit"
@click="handleSubmit"
>
{{ isLoading ? loadingText : '注册' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'success'): void
(e: 'switchToLogin'): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false
})
const emit = defineEmits<Emits>()
const { success, error: showError } = useToast()
// Form nonce for password fields (prevent autofill)
const formNonce = ref(createFormNonce())
function createFormNonce(): string {
return Math.random().toString(36).slice(2, 10)
}
// Verification code inputs
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
codeInputRefs.value[index] = el
}
// Handle verification code input
const handleCodeInput = (index: number, event: Event) => {
const input = event.target as HTMLInputElement
const value = input.value
// Only allow digits
if (!/^\d*$/.test(value)) {
input.value = codeDigits.value[index]
return
}
codeDigits.value[index] = value
// Auto-focus next input
if (value && index < 5) {
codeInputRefs.value[index + 1]?.focus()
}
// Check if all digits are filled
const fullCode = codeDigits.value.join('')
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
handleCodeComplete(fullCode)
}
}
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
// Handle backspace
if (event.key === 'Backspace') {
if (!codeDigits.value[index] && index > 0) {
// If current input is empty, move to previous and clear it
codeInputRefs.value[index - 1]?.focus()
codeDigits.value[index - 1] = ''
} else {
// Clear current input
codeDigits.value[index] = ''
}
}
// Handle arrow keys
else if (event.key === 'ArrowLeft' && index > 0) {
codeInputRefs.value[index - 1]?.focus()
} else if (event.key === 'ArrowRight' && index < 5) {
codeInputRefs.value[index + 1]?.focus()
}
}
const handleCodePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
if (cleanedData) {
// Fill digits
for (let i = 0; i < 6; i++) {
codeDigits.value[i] = cleanedData[i] || ''
}
// Focus the next empty input or the last input
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
codeInputRefs.value[focusIndex]?.focus()
// Check if all digits are filled
if (cleanedData.length === 6) {
handleCodeComplete(cleanedData)
}
}
}
const clearCodeInputs = () => {
codeDigits.value = ['', '', '', '', '', '']
codeInputRefs.value[0]?.focus()
}
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const formData = ref({
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
})
const isLoading = ref(false)
const loadingText = ref('注册中...')
const isSendingCode = ref(false)
const emailVerified = ref(false)
const verificationError = ref(false)
const codeSentAt = ref<number | null>(null)
const cooldownSeconds = ref(0)
const expireMinutes = ref(5)
const cooldownTimer = ref<number | null>(null)
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
if (cooldownSeconds.value > 0) return false
return true
})
const sendCodeButtonText = computed(() => {
if (isSendingCode.value) return '发送中...'
if (emailVerified.value) return '验证成功'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
})
const canSubmit = computed(() => {
const hasBasicInfo =
formData.value.email &&
formData.value.username &&
formData.value.password &&
formData.value.confirmPassword
if (!hasBasicInfo) return false
// If email verification is required, check if verified
if (props.requireEmailVerification && !emailVerified.value) {
return false
}
// Check password match
if (formData.value.password !== formData.value.confirmPassword) {
return false
}
// Check password length
if (formData.value.password.length < 6) {
return false
}
return true
})
// 查询并恢复验证状态
const checkAndRestoreVerificationStatus = async (email: string) => {
if (!email || !props.requireEmailVerification) return
try {
const status = await authApi.getVerificationStatus(email)
// 注意:不恢复 is_verified 状态
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
// 只恢复"有待验证验证码"的状态(冷却时间)
if (status.has_pending_code) {
codeSentAt.value = Date.now()
verificationError.value = false
// 恢复冷却时间
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
startCooldown(status.cooldown_remaining)
}
}
} catch {
// 查询失败时静默处理,不影响用户体验
}
}
// 邮箱查询防抖定时器
let emailCheckTimer: number | null = null
// 监听邮箱变化,查询验证状态
watch(
() => formData.value.email,
(newEmail, oldEmail) => {
// 邮箱变化时重置验证状态
if (newEmail !== oldEmail) {
emailVerified.value = false
verificationError.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
codeDigits.value = ['', '', '', '', '', '']
}
// 清除之前的定时器
if (emailCheckTimer !== null) {
clearTimeout(emailCheckTimer)
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(newEmail)) return
// 防抖500ms 后查询验证状态
emailCheckTimer = window.setTimeout(() => {
checkAndRestoreVerificationStatus(newEmail)
}, 500)
}
)
// Reset form when dialog opens
watch(isOpen, (newValue) => {
if (newValue) {
resetForm()
}
})
// Start cooldown timer
const startCooldown = (seconds: number) => {
// Clear existing timer if any
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
cooldownSeconds.value = seconds
cooldownTimer.value = window.setInterval(() => {
cooldownSeconds.value--
if (cooldownSeconds.value <= 0) {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
}
}, 1000)
}
// Cleanup timer on unmount
onUnmounted(() => {
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
}
if (emailCheckTimer !== null) {
clearTimeout(emailCheckTimer)
}
})
const resetForm = () => {
formData.value = {
email: '',
username: '',
password: '',
confirmPassword: '',
verificationCode: ''
}
emailVerified.value = false
verificationError.value = false
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
// Reset password field nonce
formNonce.value = createFormNonce()
// Clear timer
if (cooldownTimer.value !== null) {
clearInterval(cooldownTimer.value)
cooldownTimer.value = null
}
// Clear verification code inputs
codeDigits.value = ['', '', '', '', '', '']
}
const handleSendCode = async () => {
if (!formData.value.email) {
showError('请输入邮箱')
return
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.value.email)) {
showError('请输入有效的邮箱地址', '邮箱格式错误')
return
}
isSendingCode.value = true
try {
const response = await authApi.sendVerificationCode(formData.value.email)
if (response.success) {
codeSentAt.value = Date.now()
if (response.expire_minutes) {
expireMinutes.value = response.expire_minutes
}
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
// Start 60 second cooldown
startCooldown(60)
// Focus the first verification code input
nextTick(() => {
codeInputRefs.value[0]?.focus()
})
} else {
showError(response.message || '请稍后重试', '发送失败')
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '网络错误,请重试'
showError(errorMsg, '发送失败')
} finally {
isSendingCode.value = false
}
}
const handleCodeComplete = async (code: string) => {
if (!formData.value.email || code.length !== 6) return
// 如果已经验证成功,不再重复验证
if (emailVerified.value) return
isLoading.value = true
loadingText.value = '验证中...'
verificationError.value = false
try {
const response = await authApi.verifyEmail(formData.value.email, code)
if (response.success) {
emailVerified.value = true
success('邮箱验证通过,请继续完成注册', '验证成功')
} else {
verificationError.value = true
showError(response.message || '验证码错误', '验证失败')
// Clear the code input
clearCodeInputs()
}
} catch (error: any) {
verificationError.value = true
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '验证码错误,请重试'
showError(errorMsg, '验证失败')
// Clear the code input
clearCodeInputs()
} finally {
isLoading.value = false
}
}
const handleSubmit = async () => {
// Validate password match
if (formData.value.password !== formData.value.confirmPassword) {
showError('两次输入的密码不一致', '密码不匹配')
return
}
// Validate password length
if (formData.value.password.length < 6) {
showError('密码长度至少 6 位', '密码过短')
return
}
// Check email verification if required
if (props.requireEmailVerification && !emailVerified.value) {
showError('请先完成邮箱验证')
return
}
isLoading.value = true
loadingText.value = '注册中...'
try {
const response = await authApi.register({
email: formData.value.email,
username: formData.value.username,
password: formData.value.password
})
success(response.message || '欢迎加入!请登录以继续', '注册成功')
emit('success')
isOpen.value = false
} catch (error: any) {
const errorMsg = error.response?.data?.detail
|| error.response?.data?.error?.message
|| error.message
|| '注册失败,请重试'
showError(errorMsg, '注册失败')
} finally {
isLoading.value = false
}
}
const handleCancel = () => {
isOpen.value = false
}
const handleSwitchToLogin = () => {
emit('switchToLogin')
isOpen.value = false
}
</script>