From 3a6e25b5b1bea066e378c531c2268f1e0761726b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 9 Feb 2026 19:00:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码) - 后端: 新增 ResetPasswordRequest schema - 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成) - 前端: 登录页添加"忘记密码?"链接 Co-Authored-By: Claude Opus 4.6 --- backend/app/api/auth.py | 43 +++++ backend/app/schemas/auth.py | 16 ++ frontend/app/forgot-password/page.tsx | 259 ++++++++++++++++++++++++++ frontend/app/login/page.tsx | 17 +- frontend/lib/api.ts | 14 ++ 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 frontend/app/forgot-password/page.tsx diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index b976ed5..4204f98 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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, diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index a8eb979..e7b33e3 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 0000000..179f471 --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -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('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 ( +
+
+
+
+ +
+
+
+

密码已重置

+

请使用新密码登录

+
+ +
+
+ ) + } + + return ( +
+
+ {/* 返回 */} + + + 返回登录 + + + {/* 标题 */} +
+
+ +
+
+ 重置密码 +

+ {step === 'email' ? '输入邮箱获取验证码' : '设置新密码'} +

+
+
+ + {/* 步骤指示 */} +
+
+
+
+ +
{ e.preventDefault(); handleSendCode() } : handleResetPassword} className="space-y-5"> + {error && ( +
+ + {error} +
+ )} + + {step === 'email' ? ( + <> + {/* 邮箱 */} +
+ +
+ + 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 + /> +
+
+ + + + ) : ( + <> + {/* 验证码 */} +
+ +
+
+ + 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 + /> +
+ +
+
+ + {/* 新密码 */} +
+ +
+ + 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 + /> +
+
+ + {/* 确认密码 */} +
+ +
+ + 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 + /> +
+
+ + + + )} +
+
+
+ ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index be05544..00574c4 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -262,13 +262,18 @@ function LoginForm() { - {/* 注册链接 */} -

- 还没有账号?{' '} - - 立即注册 + {/* 注册 + 忘记密码 */} +

+ + 忘记密码? -

+

+ 还没有账号?{' '} + + 立即注册 + +

+
{/* Demo 登录 */}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 95b1e58..e1cc350 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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 + } + /** * 用户注册 */