diff --git a/api/handler_onboarding.go b/api/handler_onboarding.go index 5425e359..7989ed56 100644 --- a/api/handler_onboarding.go +++ b/api/handler_onboarding.go @@ -152,6 +152,7 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) { } func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) { + // 1. Check if current user already has a claw402 wallet models, err := s.store.AIModel().List(userID) if err != nil { return "", "", "", false, err @@ -175,6 +176,25 @@ func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, addres return existingKey, addr, model.ID, true, nil } + // 2. Check for orphan claw402 wallet from a previous account (e.g. after account reset). + // Adopt it to preserve funds. + orphan, orphanErr := s.store.AIModel().FindOrphanClaw402() + if orphanErr == nil && orphan != nil { + existingKey := strings.TrimSpace(orphan.APIKey.String()) + if existingKey != "" { + addr, addrErr := walletAddressFromPrivateKey(existingKey) + if addrErr == nil { + if adoptErr := s.store.AIModel().AdoptModel(orphan.ID, userID); adoptErr != nil { + logger.Warnf("Failed to adopt orphan claw402 wallet for user %s: %v", userID, adoptErr) + } else { + logger.Infof("✓ Adopted orphan claw402 wallet %s for new user %s (address: %s)", orphan.ID, userID, addr) + return existingKey, addr, orphan.ID, true, nil + } + } + } + } + + // 3. No existing wallet found — generate a new one privateKeyObj, genErr := gethcrypto.GenerateKey() if genErr != nil { return "", "", "", false, genErr diff --git a/api/handler_user.go b/api/handler_user.go index 2a3d502f..330fbfd3 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -102,6 +102,10 @@ func (s *Server) handleRegister(c *gin.Context) { return } + // Adopt orphan records from previous account (e.g. after account reset) + // This preserves wallet keys and exchange configs so funds are not lost. + s.adoptOrphanRecords(userID) + // Generate JWT token token, err := auth.GenerateJWT(user.ID, user.Email) if err != nil { @@ -222,6 +226,50 @@ func (s *Server) handleResetPassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"}) } +// handleResetAccount clears user authentication data so the system returns to +// uninitialized state for re-registration. Wallet keys (ai_models) are preserved +// so funds are not lost — they will be adopted by the new account during onboarding. +func (s *Server) handleResetAccount(c *gin.Context) { + err := s.store.Transaction(func(tx *gorm.DB) error { + // Delete traders and strategies (config, not funds) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{}) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{}) + // Delete users — ai_models and exchanges are intentionally kept + // so wallet private keys and exchange configs survive re-registration + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil { + return fmt.Errorf("failed to delete users: %w", err) + } + return nil + }) + if err != nil { + SafeInternalError(c, "Failed to reset account", err) + return + } + + logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized") + c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"}) +} + +// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer +// exists in the users table. This happens after account reset so the new user +// inherits the previous wallet keys and exchange configurations. +func (s *Server) adoptOrphanRecords(newUserID string) { + db := s.store.GormDB() + result := db.Model(&store.AIModel{}). + Where("user_id NOT IN (SELECT id FROM users)"). + Update("user_id", newUserID) + if result.RowsAffected > 0 { + logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID) + } + + result = db.Model(&store.Exchange{}). + Where("user_id NOT IN (SELECT id FROM users)"). + Update("user_id", newUserID) + if result.RowsAffected > 0 { + logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID) + } +} + // initUserDefaultConfigs Initialize default configs for new user func (s *Server) initUserDefaultConfigs(userID string, lang string) error { if err := s.createDefaultStrategies(userID, lang); err != nil { diff --git a/api/server.go b/api/server.go index 67c9e4a0..0f6c4c3c 100644 --- a/api/server.go +++ b/api/server.go @@ -118,6 +118,7 @@ func (s *Server) setupRoutes() { s.route(api, "POST", "/register", "Register new user", s.handleRegister) s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword) + s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount) // Routes requiring authentication protected := api.Group("/", s.authMiddleware()) diff --git a/store/ai_model.go b/store/ai_model.go index e3380d3e..e1af9d0f 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -54,6 +54,24 @@ func (s *AIModelStore) initDefaultData() error { return nil } +// FindOrphanClaw402 finds a claw402 model whose user_id no longer exists in the users table. +// Used to recover wallets after account reset. +func (s *AIModelStore) FindOrphanClaw402() (*AIModel, error) { + var model AIModel + err := s.db.Where("provider = ? AND api_key != '' AND user_id NOT IN (SELECT id FROM users)", "claw402"). + First(&model).Error + if err != nil { + return nil, err + } + return &model, nil +} + +// AdoptModel re-assigns an existing model to a new user. +func (s *AIModelStore) AdoptModel(modelID, newUserID string) error { + return s.db.Model(&AIModel{}).Where("id = ?", modelID). + Update("user_id", newUserID).Error +} + // List retrieves user's AI model list func (s *AIModelStore) List(userID string) ([]*AIModel, error) { var models []*AIModel diff --git a/store/user.go b/store/user.go index 5b084683..855a6c31 100644 --- a/store/user.go +++ b/store/user.go @@ -112,6 +112,11 @@ func (s *UserStore) UpdatePassword(userID, passwordHash string) error { }).Error } +// DeleteAll deletes all users (reset system to uninitialized state) +func (s *UserStore) DeleteAll() error { + return s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{}).Error +} + // EnsureAdmin ensures admin user exists func (s *UserStore) EnsureAdmin() error { var count int64 diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index 68d5c240..b9ff9097 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -8,6 +8,7 @@ import { DeepVoidBackground } from '../common/DeepVoidBackground' import { LanguageSwitcher } from '../common/LanguageSwitcher' import { OnboardingModeSelector } from './OnboardingModeSelector' import type { UserMode } from '../../lib/onboarding' +import { invalidateSystemConfig } from '../../lib/config' export function LoginPage() { const { language } = useLanguage() @@ -36,6 +37,27 @@ export function LoginPage() { } }, [language]) + const handleResetAccount = async () => { + if (!window.confirm(t('forgotAccountConfirm', language))) return + try { + const res = await fetch('/api/reset-account', { method: 'POST' }) + if (res.ok) { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + localStorage.removeItem('user_id') + sessionStorage.removeItem('from401') + invalidateSystemConfig() + toast.success(t('forgotAccountSuccess', language)) + setTimeout(() => { window.location.href = '/setup' }, 1500) + } else { + const data = await res.json() + toast.error(data.error || 'Reset failed') + } + } catch { + toast.error('Network error') + } + } + const handleLogin = async (e: React.FormEvent) => { e.preventDefault() setError('') @@ -145,6 +167,16 @@ export function LoginPage() { {loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'} + +
+ +
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 12d85412..48a6ef8c 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -493,6 +493,9 @@ export const translations = { registerNow: 'Sign up now', loginNow: 'Sign in now', forgotPassword: 'Forgot password?', + forgotAccount: 'Forgot account?', + forgotAccountConfirm: 'This will clear all account data and allow you to register a new account. Continue?', + forgotAccountSuccess: 'Account reset successful! You can now register a new account.', rememberMe: 'Remember me', resetPassword: 'Reset Password', resetPasswordTitle: 'Reset your password', @@ -1819,6 +1822,9 @@ export const translations = { registerNow: '立即注册', loginNow: '立即登录', forgotPassword: '忘记密码?', + forgotAccount: '忘记账户?', + forgotAccountConfirm: '这将清除所有账户数据,允许您重新注册新账户。是否继续?', + forgotAccountSuccess: '账户已重置!现在可以注册新账户了。', rememberMe: '记住我', resetPassword: '重置密码', resetPasswordTitle: '重置您的密码', @@ -3080,6 +3086,9 @@ export const translations = { registerNow: 'Daftar sekarang', loginNow: 'Masuk sekarang', forgotPassword: 'Lupa kata sandi?', + forgotAccount: 'Lupa akun?', + forgotAccountConfirm: 'Ini akan menghapus semua data akun dan memungkinkan Anda mendaftar akun baru. Lanjutkan?', + forgotAccountSuccess: 'Akun berhasil direset! Anda sekarang dapat mendaftar akun baru.', rememberMe: 'Ingat saya', resetPassword: 'Reset Kata Sandi', resetPasswordTitle: 'Reset kata sandi Anda',