mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 21:17:21 +08:00
- 重构: 将 verification 模块重命名为 email,目录结构更清晰 - 新增: 独立的邮件配置管理页面 (EmailSettings.vue) - 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览) - 新增: 查询验证状态 API,支持页面刷新后恢复验证流程 - 新增: 注册邮箱后缀白名单/黑名单限制功能 - 修复: 统一密码最小长度为 6 位(前后端一致) - 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起 - 修复: 邮件模板变量添加 HTML 转义,防止 XSS - 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件 - 优化: RegisterDialog 重写验证码输入组件,提升用户体验 - 优化: Input 组件支持 disableAutofill 属性
641 lines
18 KiB
Vue
641 lines
18 KiB
Vue
<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>
|