mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user