feat: 添加密码重置功能
- 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码) - 后端: 新增 ResetPasswordRequest schema - 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成) - 前端: 登录页添加"忘记密码?"链接 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
45c6c034e0
commit
3a6e25b5b1
@ -13,6 +13,7 @@ from app.schemas.auth import (
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
ResetPasswordRequest,
|
||||
SendEmailCodeRequest,
|
||||
UserResponse,
|
||||
)
|
||||
@ -27,6 +28,7 @@ from app.services.auth import (
|
||||
update_refresh_token,
|
||||
decode_token,
|
||||
get_user_organization_info,
|
||||
hash_password,
|
||||
)
|
||||
from app.services.verification import generate_code, verify_code
|
||||
from app.services.email import send_verification_email
|
||||
@ -323,6 +325,47 @@ async def refresh_token(
|
||||
return RefreshTokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(
|
||||
request: ResetPasswordRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
重置密码
|
||||
|
||||
- 需要先调用 /auth/send-code (purpose=reset_password) 获取验证码
|
||||
- 验证码正确后设置新密码
|
||||
"""
|
||||
# 验证验证码
|
||||
if not verify_code(request.email, request.email_code, "reset_password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# 查找用户
|
||||
user = await get_user_by_email(db, request.email)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱未注册",
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
user.password_hash = hash_password(request.new_password)
|
||||
|
||||
# 审计日志
|
||||
await log_action(
|
||||
db, "reset_password", "user", user.id, user.id,
|
||||
user.name, user.role.value,
|
||||
ip_address=req.client.host if req.client else None,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "密码已重置,请使用新密码登录"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
req: Request,
|
||||
|
||||
@ -81,6 +81,22 @@ class BindEmailRequest(BaseModel):
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""重置密码请求(通过邮箱验证码)"""
|
||||
email: EmailStr
|
||||
email_code: str = Field(..., min_length=4, max_length=8)
|
||||
new_password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"email_code": "123456",
|
||||
"new_password": "newpassword123"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""修改密码请求"""
|
||||
old_password: str
|
||||
|
||||
259
frontend/app/forgot-password/page.tsx
Normal file
259
frontend/app/forgot-password/page.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ShieldCheck, AlertCircle, CheckCircle2, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
type Step = 'email' | 'code' | 'done'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailCode, setEmailCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
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) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setCountdown(60)
|
||||
setStep('code')
|
||||
return
|
||||
}
|
||||
|
||||
await api.sendEmailCode({ email, purpose: 'reset_password' })
|
||||
setCountdown(60)
|
||||
setStep('code')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '发送验证码失败'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setCodeSending(false)
|
||||
}
|
||||
}, [email, countdown])
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!emailCode) {
|
||||
setError('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setStep('done')
|
||||
return
|
||||
}
|
||||
|
||||
await api.resetPassword({ email, email_code: emailCode, new_password: newPassword })
|
||||
setStep('done')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '重置密码失败'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功页
|
||||
if (step === 'done') {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm space-y-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-green/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-accent-green" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">密码已重置</h2>
|
||||
<p className="text-sm text-text-secondary mt-2">请使用新密码登录</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/login')}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* 返回 */}
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回登录
|
||||
</Link>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)]">
|
||||
<ShieldCheck className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-text-primary">重置密码</span>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{step === 'email' ? '输入邮箱获取验证码' : '设置新密码'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤指示 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex-1 h-1 rounded-full ${step === 'email' ? 'bg-accent-indigo' : 'bg-accent-indigo'}`} />
|
||||
<div className={`flex-1 h-1 rounded-full ${step === 'code' ? 'bg-accent-indigo' : 'bg-border-subtle'}`} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={step === 'email' ? (e) => { e.preventDefault(); handleSendCode() } : handleResetPassword} className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-accent-coral/10 text-accent-coral rounded-lg text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'email' ? (
|
||||
<>
|
||||
{/* 邮箱 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">邮箱</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="请输入注册时的邮箱"
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={codeSending || !email}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{codeSending ? '发送中...' : '获取验证码'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 验证码 */}
|
||||
<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="请输入 6 位验证码"
|
||||
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}
|
||||
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">新密码</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="至少 6 位密码"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(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="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={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '重置中...' : '重置密码'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -262,13 +262,18 @@ function LoginForm() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 注册链接 */}
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
还没有账号?{' '}
|
||||
<Link href="/register" className="text-accent-indigo hover:underline font-medium">
|
||||
立即注册
|
||||
{/* 注册 + 忘记密码 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Link href="/forgot-password" className="text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-text-secondary">
|
||||
还没有账号?{' '}
|
||||
<Link href="/register" className="text-accent-indigo hover:underline font-medium">
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Demo 登录 */}
|
||||
<div className="pt-6 border-t border-border-subtle">
|
||||
|
||||
@ -115,6 +115,12 @@ export interface SendEmailCodeResponse {
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
email: string
|
||||
email_code: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
@ -276,6 +282,14 @@ class ApiClient {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
async resetPassword(data: ResetPasswordRequest): Promise<{ message: string }> {
|
||||
const response = await this.client.post<{ message: string }>('/auth/reset-password', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user