feat: 实现邮箱验证码注册/登录功能
- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出) - 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途 - 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True - 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式 - 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时) - 前端: 登录页增加密码登录/验证码登录双Tab切换 - 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
864af19011
commit
d4081345f7
+116
-16
@@ -1,10 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock } from 'lucide-react'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
@@ -12,13 +16,24 @@ function LoginForm() {
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailCode, setEmailCode] = useState('')
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('password')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false)
|
||||
const [codeSending, setCodeSending] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 如果 URL 有 role 参数,自动触发 demo 登录
|
||||
const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => {
|
||||
const emailMap = {
|
||||
creator: 'creator@demo.com',
|
||||
@@ -59,12 +74,43 @@ function LoginForm() {
|
||||
}
|
||||
}, [roleFromUrl])
|
||||
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请先输入邮箱')
|
||||
return
|
||||
}
|
||||
if (countdown > 0) return
|
||||
|
||||
setError('')
|
||||
setCodeSending(true)
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setCountdown(60)
|
||||
return
|
||||
}
|
||||
|
||||
await api.sendEmailCode({ email, purpose: 'login' })
|
||||
setCountdown(60)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : '发送验证码失败'
|
||||
setError(error)
|
||||
} finally {
|
||||
setCodeSending(false)
|
||||
}
|
||||
}, [email, countdown])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await login({ email, password })
|
||||
const credentials = loginMode === 'code'
|
||||
? { email, email_code: emailCode }
|
||||
: { email, password }
|
||||
|
||||
const result = await login(credentials)
|
||||
|
||||
if (result.success) {
|
||||
const stored = localStorage.getItem('miaosi_user')
|
||||
@@ -114,6 +160,32 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录方式切换 */}
|
||||
<div className="flex bg-bg-elevated rounded-xl p-1 border border-border-subtle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('password'); setError('') }}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
loginMode === 'password'
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('code'); setError('') }}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
loginMode === 'code'
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
@@ -138,20 +210,48 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
{loginMode === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">验证码</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={codeSending || countdown > 0 || !email}
|
||||
className="px-4 py-3.5 rounded-xl bg-accent-indigo/10 text-accent-indigo font-medium text-sm whitespace-nowrap hover:bg-accent-indigo/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{codeSending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone } from 'lucide-react'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone, KeyRound } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { UserRole } from '@/lib/api'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
const roleOptions: { value: UserRole; label: string; desc: string }[] = [
|
||||
{ value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' },
|
||||
@@ -21,9 +23,47 @@ export default function RegisterPage() {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [emailCode, setEmailCode] = useState('')
|
||||
const [role, setRole] = useState<UserRole>('creator')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [codeSending, setCodeSending] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请先输入邮箱')
|
||||
return
|
||||
}
|
||||
if (countdown > 0) return
|
||||
|
||||
setError('')
|
||||
setCodeSending(true)
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Mock: 模拟发送
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setCountdown(60)
|
||||
return
|
||||
}
|
||||
|
||||
await api.sendEmailCode({ email, purpose: 'register' })
|
||||
setCountdown(60)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : '发送验证码失败'
|
||||
setError(error)
|
||||
} finally {
|
||||
setCodeSending(false)
|
||||
}
|
||||
}, [email, countdown])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -33,8 +73,12 @@ export default function RegisterPage() {
|
||||
setError('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!email && !phone) {
|
||||
setError('请填写邮箱或手机号')
|
||||
if (!email) {
|
||||
setError('请填写邮箱')
|
||||
return
|
||||
}
|
||||
if (!emailCode && !USE_MOCK) {
|
||||
setError('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
@@ -50,10 +94,11 @@ export default function RegisterPage() {
|
||||
|
||||
const result = await register({
|
||||
name: name.trim(),
|
||||
email: email || undefined,
|
||||
email,
|
||||
phone: phone || undefined,
|
||||
password,
|
||||
role,
|
||||
email_code: emailCode || '000000',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -158,10 +203,37 @@ export default function RegisterPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">邮箱验证码</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={codeSending || countdown > 0 || !email}
|
||||
className="px-4 py-3.5 rounded-xl bg-accent-indigo/10 text-accent-indigo font-medium text-sm whitespace-nowrap hover:bg-accent-indigo/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{codeSending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机号 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">
|
||||
|
||||
@@ -94,7 +94,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (!mockUser) {
|
||||
return { success: false, error: '用户不存在' }
|
||||
}
|
||||
if (mockUser.password !== credentials.password) {
|
||||
// 验证码登录或密码登录
|
||||
if (credentials.email_code) {
|
||||
// 验证码登录 mock: 任何验证码都通过
|
||||
} else if (mockUser.password !== credentials.password) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
const { password: _, ...userWithoutPassword } = mockUser
|
||||
|
||||
+21
-2
@@ -93,15 +93,26 @@ export interface LoginRequest {
|
||||
email?: string
|
||||
phone?: string
|
||||
password?: string
|
||||
sms_code?: string
|
||||
email_code?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email?: string
|
||||
email: string
|
||||
phone?: string
|
||||
password: string
|
||||
name: string
|
||||
role: UserRole
|
||||
email_code: string
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string
|
||||
purpose: 'register' | 'login' | 'reset_password'
|
||||
}
|
||||
|
||||
export interface SendEmailCodeResponse {
|
||||
message: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -257,6 +268,14 @@ class ApiClient {
|
||||
|
||||
// ==================== 认证 ====================
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
async sendEmailCode(data: SendEmailCodeRequest): Promise<SendEmailCodeResponse> {
|
||||
const response = await this.client.post<SendEmailCodeResponse>('/auth/send-code', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user