diff --git a/api/server.go b/api/server.go index 58b8211f..c1767e6f 100644 --- a/api/server.go +++ b/api/server.go @@ -79,6 +79,7 @@ func (s *Server) setupRoutes() { api.POST("/login", s.handleLogin) api.POST("/verify-otp", s.handleVerifyOTP) api.POST("/complete-registration", s.handleCompleteRegistration) + api.POST("/reset-password", s.handleResetPassword) // 系统支持的模型和交易所(无需认证) api.GET("/supported-models", s.handleGetSupportedModels) @@ -1728,6 +1729,50 @@ func (s *Server) handleVerifyOTP(c *gin.Context) { }) } +// handleResetPassword 重置密码(通过邮箱 + OTP 验证) +func (s *Server) handleResetPassword(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + NewPassword string `json:"new_password" binding:"required,min=6"` + OTPCode string `json:"otp_code" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 查询用户 + user, err := s.database.GetUserByEmail(req.Email) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "邮箱不存在"}) + return + } + + // 验证 OTP + if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Google Authenticator 验证码错误"}) + return + } + + // 生成新密码哈希 + newPasswordHash, err := auth.HashPassword(req.NewPassword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码处理失败"}) + return + } + + // 更新密码 + err = s.database.UpdateUserPassword(user.ID, newPasswordHash) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"}) + return + } + + log.Printf("✓ 用户 %s 密码已重置", user.Email) + c.JSON(http.StatusOK, gin.H{"message": "密码重置成功,请使用新密码登录"}) +} + // initUserDefaultConfigs 为新用户初始化默认的模型和交易所配置 func (s *Server) initUserDefaultConfigs(userID string) error { // 注释掉自动创建默认配置,让用户手动添加 diff --git a/config/database.go b/config/database.go index c3aa171d..a2fd5732 100644 --- a/config/database.go +++ b/config/database.go @@ -546,6 +546,16 @@ func (d *Database) UpdateUserOTPVerified(userID string, verified bool) error { return err } +// UpdateUserPassword 更新用户密码 +func (d *Database) UpdateUserPassword(userID, passwordHash string) error { + _, err := d.db.Exec(` + UPDATE users + SET password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, passwordHash, userID) + return err +} + // GetAIModels 获取用户的AI模型配置 func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) { rows, err := d.db.Query(` diff --git a/web/src/App.tsx b/web/src/App.tsx index a7e6d82b..147eca2f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { EquityChart } from './components/EquityChart' import { AITradersPage } from './components/AITradersPage' import { LoginPage } from './components/LoginPage' import { RegisterPage } from './components/RegisterPage' +import { ResetPasswordPage } from './components/ResetPasswordPage' import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' import HeaderBar from './components/landing/HeaderBar' @@ -230,6 +231,9 @@ function App() { if (route === '/register') { return } + if (route === '/reset-password') { + return + } if (route === '/competition') { return (
+
+ +
{error && ( diff --git a/web/src/components/ResetPasswordPage.tsx b/web/src/components/ResetPasswordPage.tsx new file mode 100644 index 00000000..4bf233d7 --- /dev/null +++ b/web/src/components/ResetPasswordPage.tsx @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { useLanguage } from '../contexts/LanguageContext'; +import { t } from '../i18n/translations'; +import { Header } from './Header'; +import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'; + +export function ResetPasswordPage() { + const { language } = useLanguage(); + const { resetPassword } = useAuth(); + const [email, setEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(false); + + // 验证两次密码是否一致 + if (newPassword !== confirmPassword) { + setError(t('passwordMismatch', language)); + return; + } + + setLoading(true); + + const result = await resetPassword(email, newPassword, otpCode); + + if (result.success) { + setSuccess(true); + // 3秒后跳转到登录页面 + setTimeout(() => { + window.history.pushState({}, '', '/login'); + window.dispatchEvent(new PopStateEvent('popstate')); + }, 3000); + } else { + setError(result.message || t('resetPasswordFailed', language)); + } + + setLoading(false); + }; + + return ( +
+
+ +
+
+ {/* Back to Login */} + + + {/* Logo */} +
+
+ +
+

+ {t('resetPasswordTitle', language)} +

+

+ 使用邮箱和 Google Authenticator 重置密码 +

+
+ + {/* Reset Password Form */} +
+ {success ? ( +
+
+

+ {t('resetPasswordSuccess', language)} +

+

+ 3秒后将自动跳转到登录页面... +

+
+ ) : ( +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 rounded" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + placeholder={t('emailPlaceholder', language)} + required + /> +
+ +
+ +
+ setNewPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 rounded" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + placeholder={t('newPasswordPlaceholder', language)} + required + minLength={6} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 rounded" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + placeholder={t('confirmPasswordPlaceholder', language)} + required + minLength={6} + /> + +
+
+ +
+ +
+
📱
+

+ 打开 Google Authenticator 获取6位验证码 +

+
+ setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + className="w-full px-3 py-2 rounded text-center text-2xl font-mono" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + placeholder={t('otpPlaceholder', language)} + maxLength={6} + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ )} +
+
+
+
+ ); +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 96f0dc72..5e87d331 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -37,6 +37,11 @@ interface AuthContextType { userID: string, otpCode: string ) => Promise<{ success: boolean; message?: string }> + resetPassword: ( + email: string, + newPassword: string, + otpCode: string + ) => Promise<{ success: boolean; message?: string }> logout: () => void isLoading: boolean } @@ -220,6 +225,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } + const resetPassword = async ( + email: string, + newPassword: string, + otpCode: string + ) => { + try { + const response = await fetch('/api/reset-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + new_password: newPassword, + otp_code: otpCode, + }), + }) + + const data = await response.json() + + if (response.ok) { + return { success: true, message: data.message } + } else { + return { success: false, message: data.error } + } + } catch (error) { + return { success: false, message: '密码重置失败,请重试' } + } + } + const logout = () => { setUser(null) setToken(null) @@ -236,6 +271,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { register, verifyOTP, completeRegistration, + resetPassword, logout, isLoading, }} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 6916544a..233adff4 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -336,6 +336,14 @@ export const translations = { forgotPassword: 'Forgot password?', rememberMe: 'Remember me', otpCode: 'OTP Code', + resetPassword: 'Reset Password', + resetPasswordTitle: 'Reset your password', + newPassword: 'New Password', + newPasswordPlaceholder: 'Enter new password (at least 6 characters)', + resetPasswordButton: 'Reset Password', + resetPasswordSuccess: 'Password reset successful! Please login with your new password', + resetPasswordFailed: 'Password reset failed', + backToLogin: 'Back to Login', scanQRCode: 'Scan QR Code', enterOTPCode: 'Enter 6-digit OTP code', verifyOTP: 'Verify OTP', @@ -811,6 +819,14 @@ export const translations = { loginNow: '立即登录', forgotPassword: '忘记密码?', rememberMe: '记住我', + resetPassword: '重置密码', + resetPasswordTitle: '重置您的密码', + newPassword: '新密码', + newPasswordPlaceholder: '请输入新密码(至少6位)', + resetPasswordButton: '重置密码', + resetPasswordSuccess: '密码重置成功!请使用新密码登录', + resetPasswordFailed: '密码重置失败', + backToLogin: '返回登录', otpCode: 'OTP验证码', scanQRCode: '扫描二维码', enterOTPCode: '输入6位OTP验证码', diff --git a/web/tsconfig.json b/web/tsconfig.json index a7fc6fbf..6d9748fa 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -21,5 +21,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/test"], "references": [{ "path": "./tsconfig.node.json" }] }