feat: add "forgot account" reset flow with wallet preservation

Add account reset functionality for users who forgot their login credentials.
The reset clears authentication data while preserving wallet private keys and
exchange configs, which are automatically adopted by the new account on
re-registration to prevent fund loss.

- Add POST /api/reset-account endpoint
- Add "Forgot account?" button on login page (zh/en/id)
- Orphan ai_models and exchanges are re-assigned to new user on register
- Onboarding reuses existing claw402 wallet instead of generating new one

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-04-10 18:00:56 +08:00
parent 80272c0d5a
commit 4f0a922779
7 changed files with 133 additions and 0 deletions
+20
View File
@@ -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
+48
View File
@@ -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 {
+1
View File
@@ -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())
+18
View File
@@ -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
+5
View File
@@ -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
+32
View File
@@ -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'}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={handleResetAccount}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors"
>
{t('forgotAccount', language)}
</button>
</div>
</div>
</div>
+9
View File
@@ -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',