diff --git a/.env.example b/.env.example index 5eafa687..22cd7ef2 100644 --- a/.env.example +++ b/.env.example @@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false # Optional: External Services # =========================================== -# Telegram notifications (optional) -# TELEGRAM_BOT_TOKEN=your-bot-token -# TELEGRAM_CHAT_ID=your-chat-id - DB_TYPE=postgres DB_HOST=10. DB_PORT=5432 diff --git a/api/server.go b/api/server.go index 9e5fcbd6..59370bb5 100644 --- a/api/server.go +++ b/api/server.go @@ -150,32 +150,63 @@ func (s *Server) setupRoutes() { // Logout (add to blacklist) s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) + // User account management + s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", + `Body: {"new_password":""}`, + s.handleChangePassword) + // Server IP query (requires authentication, for whitelist configuration) s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP) // AI trader management - s.route(protected, "GET", "/my-traders", "List user's traders with status", s.handleTraderList) - s.route(protected, "GET", "/traders/:id/config", "Get full trader configuration", s.handleGetTraderConfig) + s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status", + `Returns: [{"trader_id":"","trader_name":"","is_running":}] +NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this endpoint before querying data.`, + s.handleTraderList) + s.routeWithSchema(protected, "GET", "/traders/:id/config", "Get full trader configuration", + `:id = trader_id from GET /api/my-traders`, + s.handleGetTraderConfig) s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader", - `Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} -Use exchange_id and ai_model_id from the Account State block injected at conversation start — no need to GET them again.`, + `Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} +IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`, s.handleCreateTrader) - s.route(protected, "PUT", "/traders/:id", "Update trader configuration", s.handleUpdateTrader) - s.route(protected, "DELETE", "/traders/:id", "Delete trader", s.handleDeleteTrader) - s.route(protected, "POST", "/traders/:id/start", "Start trader — begins live trading", s.handleStartTrader) - s.route(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading", s.handleStopTrader) + s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration", + `:id = trader_id from GET /api/my-traders +Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":,"is_cross_margin":} +Only include fields you want to change.`, + s.handleUpdateTrader) + s.routeWithSchema(protected, "DELETE", "/traders/:id", "Delete trader", + `:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`, + s.handleDeleteTrader) + s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading", + `:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`, + s.handleStartTrader) + s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading", + `:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`, + s.handleStopTrader) s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt", `Body: {"prompt":""}`, s.handleUpdateTraderPrompt) - s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance) + s.routeWithSchema(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", + `:id = trader_id from GET /api/my-traders. No request body needed. Refreshes initial_balance from the exchange.`, + s.handleSyncBalance) s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position", - `Body: {"symbol":""}`, + `:id = trader_id from GET /api/my-traders. +Body: {"symbol":""}`, s.handleClosePosition) - s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility", s.handleToggleCompetition) - s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info", s.handleGetGridRiskInfo) + s.routeWithSchema(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility", + `:id = trader_id from GET /api/my-traders. +Body: {"show_in_competition":}`, + s.handleToggleCompetition) + s.routeWithSchema(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info", + `:id = trader_id from GET /api/my-traders.`, + s.handleGetGridRiskInfo) // AI model configuration - s.route(protected, "GET", "/models", "List AI model configs — returns id, name, provider, enabled status", s.handleGetModelConfigs) + s.routeWithSchema(protected, "GET", "/models", "List AI model configs", + `Returns: [{"id":"","name":"","provider":"","enabled":}] +CRITICAL: The "id" field (e.g. "abc123_deepseek") is what you must use for ai_model_id. The "provider" field ("deepseek") is NOT valid as an id.`, + s.handleGetModelConfigs) s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider", `Body: {"models":{"":{"enabled":,"api_key":"","custom_api_url":"","custom_model_name":""}}} model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude" @@ -183,7 +214,10 @@ Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.de s.handleUpdateModelConfigs) // Exchange configuration - s.route(protected, "GET", "/exchanges", "List exchange accounts — returns id, exchange_type, account_name, enabled", s.handleGetExchangeConfigs) + s.routeWithSchema(protected, "GET", "/exchanges", "List exchange accounts", + `Returns: [{"id":"","exchange_type":"","account_name":"","enabled":}] +CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`, + s.handleGetExchangeConfigs) s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account", `Body: {"exchange_type":"","account_name":"","enabled":true,"api_key":"","secret_key":"","passphrase":""} exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX) @@ -194,19 +228,40 @@ Required fields by exchange: aster: aster_user + aster_signer + aster_private_key lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`, s.handleCreateExchange) - s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs) - s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange account", s.handleDeleteExchange) + s.routeWithSchema(protected, "PUT", "/exchanges", "Update an existing exchange account configuration", + `Body: {"id":"","exchange_type":"","account_name":"","enabled":,"api_key":"","secret_key":"","passphrase":""} +Use this to enable/disable an exchange or update API credentials. The "id" field is required to identify which exchange to update.`, + s.handleUpdateExchangeConfigs) + s.routeWithSchema(protected, "DELETE", "/exchanges/:id", "Delete exchange account", + `:id = EXACT id from GET /api/exchanges. Permanently removes the exchange account and disconnects any traders using it.`, + s.handleDeleteExchange) // Telegram bot configuration - s.route(protected, "GET", "/telegram", "Get Telegram bot configuration", s.handleGetTelegramConfig) - s.route(protected, "POST", "/telegram", "Update Telegram bot token/model", s.handleUpdateTelegramConfig) - s.route(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", s.handleUpdateTelegramModel) - s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", s.handleUnbindTelegram) + s.routeWithSchema(protected, "GET", "/telegram", "Get Telegram bot configuration", + `Returns: {"bot_token":"","model_id":"","chat_id":""}`, + s.handleGetTelegramConfig) + s.routeWithSchema(protected, "POST", "/telegram", "Set Telegram bot token and AI model", + `Body: {"bot_token":"","model_id":""} +Both fields are required. After saving, the user must send /start in Telegram to bind their account.`, + s.handleUpdateTelegramConfig) + s.routeWithSchema(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", + `Body: {"model_id":""}`, + s.handleUpdateTelegramModel) + s.routeWithSchema(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", + `No body needed. Clears the Telegram chat_id binding so the user can re-bind with /start.`, + s.handleUnbindTelegram) // Strategy management - s.route(protected, "GET", "/strategies", "List user's strategies", s.handleGetStrategies) - s.route(protected, "GET", "/strategies/active", "Get active strategy", s.handleGetActiveStrategy) - s.route(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs", s.handleGetDefaultStrategyConfig) + s.routeWithSchema(protected, "GET", "/strategies", "List user's strategies", + `Returns: [{"id":"","name":"","is_active":,"is_default":}] +CRITICAL: Always use the "id" field for strategy_id.`, + s.handleGetStrategies) + s.routeWithSchema(protected, "GET", "/strategies/active", "Get the currently active strategy", + `Returns the strategy marked is_active=true for this user, or the system default. Use this to find which strategy is currently in use.`, + s.handleGetActiveStrategy) + s.routeWithSchema(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs", + `No parameters needed. Returns a complete StrategyConfig object with all fields populated with recommended defaults. Read this before building a custom config.`, + s.handleGetDefaultStrategyConfig) s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt) s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun) s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy) @@ -262,9 +317,17 @@ StrategyConfig fields: IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying. After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`, s.handleUpdateStrategy) - s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy) - s.route(protected, "POST", "/strategies/:id/activate", "Set strategy as active for a trader", s.handleActivateStrategy) - s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy) + s.routeWithSchema(protected, "DELETE", "/strategies/:id", "Delete strategy", + `:id = EXACT id from GET /api/strategies. Cannot delete a strategy that is currently assigned to a running trader.`, + s.handleDeleteStrategy) + s.routeWithSchema(protected, "POST", "/strategies/:id/activate", "Mark a strategy as the active strategy for this user", + `:id = EXACT id from GET /api/strategies. +No request body needed. Sets this strategy as is_active=true (and deactivates the previous active strategy). +After activating, create or update a trader with this strategy_id to apply it.`, + s.handleActivateStrategy) + s.routeWithSchema(protected, "POST", "/strategies/:id/duplicate", "Duplicate an existing strategy", + `:id = EXACT id from GET /api/strategies. Creates a copy with " (copy)" appended to the name.`, + s.handleDuplicateStrategy) // Debate Arena s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates) @@ -280,17 +343,46 @@ After updating, always GET /api/strategies/:id to verify and show the user actua s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream) // Data for specified trader (using query parameter ?trader_id=xxx) - s.route(protected, "GET", "/status", "Trader running status (?trader_id=)", s.handleStatus) - s.route(protected, "GET", "/account", "Account balance and equity (?trader_id=)", s.handleAccount) - s.route(protected, "GET", "/positions", "Current open positions (?trader_id=)", s.handlePositions) - s.route(protected, "GET", "/positions/history", "Position history (?trader_id=)", s.handlePositionHistory) - s.route(protected, "GET", "/trades", "Trade records (?trader_id=)", s.handleTrades) - s.route(protected, "GET", "/orders", "All orders (?trader_id=)", s.handleOrders) - s.route(protected, "GET", "/orders/:id/fills", "Order fill details", s.handleOrderFills) - s.route(protected, "GET", "/open-orders", "Open orders from exchange (?trader_id=)", s.handleOpenOrders) - s.route(protected, "GET", "/decisions", "AI trading decisions (?trader_id=)", s.handleDecisions) - s.route(protected, "GET", "/decisions/latest", "Latest AI decisions (?trader_id=)", s.handleLatestDecisions) - s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id=)", s.handleStatistics) + // IMPORTANT: All ?trader_id= values must be the EXACT "trader_id" field from GET /api/my-traders + s.routeWithSchema(protected, "GET", "/status", "Trader running status", + `Query: ?trader_id= +Returns: {"is_running":,"trader_id":""}`, + s.handleStatus) + s.routeWithSchema(protected, "GET", "/account", "Account balance and equity", + `Query: ?trader_id= +Returns: {"balance":,"equity":,"unrealized_pnl":,"initial_balance":,"total_return_pct":}`, + s.handleAccount) + s.routeWithSchema(protected, "GET", "/positions", "Current open positions", + `Query: ?trader_id= +Returns: [{"symbol":"","side":"long|short","size":,"entry_price":,"mark_price":,"unrealized_pnl":,"leverage":}]`, + s.handlePositions) + s.routeWithSchema(protected, "GET", "/positions/history", "Closed position history", + `Query: ?trader_id=&limit=`, + s.handlePositionHistory) + s.routeWithSchema(protected, "GET", "/trades", "Trade records", + `Query: ?trader_id=&limit=`, + s.handleTrades) + s.routeWithSchema(protected, "GET", "/orders", "All order records", + `Query: ?trader_id=&limit=`, + s.handleOrders) + s.routeWithSchema(protected, "GET", "/orders/:id/fills", "Order fill details", + `:id = order id from GET /api/orders`, + s.handleOrderFills) + s.routeWithSchema(protected, "GET", "/open-orders", "Open orders currently on exchange", + `Query: ?trader_id=`, + s.handleOpenOrders) + s.routeWithSchema(protected, "GET", "/decisions", "AI trading decisions (decision records)", + `Query: ?trader_id=&limit= +Returns: [{"id":"","symbol":"","action":"open_long|open_short|close_long|close_short|hold","confidence":,"reasoning":"","created_at":""}]`, + s.handleDecisions) + s.routeWithSchema(protected, "GET", "/decisions/latest", "Latest AI decisions (most recent scan results)", + `Query: ?trader_id= +Returns the most recent AI decision for each symbol analyzed in the last scan cycle.`, + s.handleLatestDecisions) + s.routeWithSchema(protected, "GET", "/statistics", "Trading performance statistics", + `Query: ?trader_id= +Returns: {"total_trades":,"winning_trades":,"win_rate":,"total_pnl":,"sharpe_ratio":,"max_drawdown":}`, + s.handleStatistics) // Backtest routes backtest := protected.Group("/backtest") @@ -309,12 +401,11 @@ func (s *Server) handleHealth(c *gin.Context) { // handleGetSystemConfig Get system configuration (configuration that client needs to know) func (s *Server) handleGetSystemConfig(c *gin.Context) { - cfg := config.Get() - + userCount, _ := s.store.User().Count() c.JSON(http.StatusOK, gin.H{ - "registration_enabled": cfg.RegistrationEnabled, - "btc_eth_leverage": 10, // Default value - "altcoin_leverage": 5, // Default value + "initialized": userCount > 0, + "btc_eth_leverage": 10, + "altcoin_leverage": 5, }) } @@ -3153,8 +3244,8 @@ func (s *Server) handleLogout(c *gin.Context) { } // handleRegister Handle user registration request. -// Registration is only open when no users exist yet (first-time setup) OR when -// explicitly enabled via REGISTRATION_ENABLED=true env var AND within MAX_USERS limit. +// handleRegister allows registration only when no users exist yet (first-time setup). +// This is a single-user system; subsequent registrations are permanently closed. func (s *Server) handleRegister(c *gin.Context) { userCount, err := s.store.User().Count() if err != nil { @@ -3162,21 +3253,9 @@ func (s *Server) handleRegister(c *gin.Context) { return } - // First-time setup: allow registration when DB is empty, regardless of config. - firstTimeSetup := userCount == 0 - - if !firstTimeSetup { - // After first user exists: require explicit opt-in via REGISTRATION_ENABLED=true. - if !config.Get().RegistrationEnabled { - c.JSON(http.StatusForbidden, gin.H{"error": "Registration is disabled"}) - return - } - // Enforce max users limit. - maxUsers := config.Get().MaxUsers - if maxUsers > 0 && userCount >= maxUsers { - c.JSON(http.StatusForbidden, gin.H{"error": "Maximum number of users reached"}) - return - } + if userCount > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"}) + return } var req struct { @@ -3278,6 +3357,28 @@ func (s *Server) handleLogin(c *gin.Context) { }) } +// handleChangePassword changes the password for the currently authenticated user. +func (s *Server) handleChangePassword(c *gin.Context) { + userID := c.GetString("user_id") + var req struct { + NewPassword string `json:"new_password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + SafeBadRequest(c, "new_password is required (min 8 chars)") + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + SafeInternalError(c, "Password processing failed", err) + return + } + if err := s.store.User().UpdatePassword(userID, hash); err != nil { + SafeInternalError(c, "Failed to update password", err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) +} + // handleResetPassword Reset password via email and new password func (s *Server) handleResetPassword(c *gin.Context) { var req struct { diff --git a/config/config.go b/config/config.go index 46b9a352..56ad3601 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "nofx/experience" "nofx/mcp" "os" @@ -16,10 +15,8 @@ var global *Config // Only contains truly global config, trading related config is at trader/strategy level type Config struct { // Service configuration - APIServerPort int - JWTSecret string - RegistrationEnabled bool - MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10) + APIServerPort int + JWTSecret string // Database configuration DBType string // sqlite or postgres @@ -46,17 +43,12 @@ type Config struct { AlpacaSecretKey string // Alpaca secret key TwelveDataKey string // TwelveData API key for forex & metals - // Telegram Bot configuration - TelegramBotToken string // TELEGRAM_BOT_TOKEN (required to enable bot) - TelegramAdminChatID int64 // TELEGRAM_ADMIN_CHAT_ID (optional, 0 = auto-bind on first /start) } // Init initializes global configuration (from .env) func Init() { cfg := &Config{ APIServerPort: 8080, - RegistrationEnabled: false, // Default: closed after first user registers (first-time setup always allowed) - MaxUsers: 1, // Default: single-user deployment ExperienceImprovement: true, // Default: enabled to help improve the product // Database defaults DBType: "sqlite", @@ -76,16 +68,6 @@ func Init() { cfg.JWTSecret = "default-jwt-secret-change-in-production" } - if v := os.Getenv("REGISTRATION_ENABLED"); v != "" { - cfg.RegistrationEnabled = strings.ToLower(v) == "true" - } - - if v := os.Getenv("MAX_USERS"); v != "" { - if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 { - cfg.MaxUsers = maxUsers - } - } - if v := os.Getenv("API_SERVER_PORT"); v != "" { if port, err := strconv.Atoi(v); err == nil && port > 0 { cfg.APIServerPort = port @@ -109,17 +91,6 @@ func Init() { cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY") cfg.TwelveDataKey = os.Getenv("TWELVEDATA_API_KEY") - // Telegram Bot configuration - cfg.TelegramBotToken = os.Getenv("TELEGRAM_BOT_TOKEN") - if chatIDStr := os.Getenv("TELEGRAM_ADMIN_CHAT_ID"); chatIDStr != "" { - if id, err := strconv.ParseInt(chatIDStr, 10, 64); err == nil { - cfg.TelegramAdminChatID = id - } else { - // logger may not be init yet, use fmt - fmt.Printf("WARNING: TELEGRAM_ADMIN_CHAT_ID invalid value %q, ignoring: %v\n", chatIDStr, err) - } - } - // Database configuration if v := os.Getenv("DB_TYPE"); v != "" { cfg.DBType = strings.ToLower(v) diff --git a/start.sh b/start.sh index 725b490e..c0c4232e 100755 --- a/start.sh +++ b/start.sh @@ -202,48 +202,6 @@ check_database() { fi } -# ------------------------------------------------------------------------ -# First-time Setup: Telegram Bot Token -# ------------------------------------------------------------------------ -setup_telegram() { - if is_env_configured "TELEGRAM_BOT_TOKEN"; then - local token=$(grep "^TELEGRAM_BOT_TOKEN=" .env | cut -d'=' -f2- | tr -d '"'"'") - print_success "Telegram Bot Token configured: ${token:0:10}..." - return - fi - - echo "" - echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e "${CYAN} 🤖 Telegram Bot Setup (required)${NC}" - echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo "" - echo " Don't have a Bot Token yet? Get one in 60 seconds:" - echo "" - echo " 1. Open Telegram, search for @BotFather" - echo " 2. Send /newbot" - echo " 3. Enter a bot name (e.g. MyTradingBot)" - echo " 4. Enter a username ending in 'bot' (e.g. my_trading_bot)" - echo " 5. BotFather gives you a token like:" - echo " 1234567890:AABBCCDDEEFFaabbccddeeff..." - echo "" - while true; do - read -p " Paste your Bot Token: " bot_token - bot_token=$(echo "$bot_token" | tr -d ' ') - if [ -z "$bot_token" ]; then - print_warning "Token cannot be empty, please try again" - continue - fi - if [[ ! "$bot_token" =~ ^[0-9]+:.+ ]]; then - print_warning "Invalid token format (expected digits:letters, e.g. 123456:ABC...) — please retry" - continue - fi - break - done - set_env_var "TELEGRAM_BOT_TOKEN" "$bot_token" - print_success "Bot Token saved ✅" - echo "" -} - # ------------------------------------------------------------------------ # Service Management: Start # ------------------------------------------------------------------------ @@ -260,9 +218,6 @@ start() { install -m 700 -d data fi - # Interactive setup for first-time users - setup_telegram - echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" print_info "Starting services..." @@ -277,12 +232,11 @@ start() { echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" echo "" - echo " 1. Open Telegram and find your bot" - echo " 2. Send /start to bind your account" - echo " 3. The bot will guide you through AI model and exchange setup" - echo " 4. Once configured, just chat to trade" + echo " 1. Open the web dashboard to register and configure" + echo " 2. Add an AI model and exchange in Settings" + echo " 3. (Optional) Add a Telegram bot token in Settings → Telegram" echo "" - echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC} (optional)" + echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC}" echo -e " View logs: ${YELLOW}./start.sh logs${NC}" echo -e " Stop: ${YELLOW}./start.sh stop${NC}" echo "" diff --git a/store/user.go b/store/user.go index 73c9a671..5b084683 100644 --- a/store/user.go +++ b/store/user.go @@ -97,6 +97,13 @@ func (s *UserStore) GetAllIDs() ([]string, error) { return userIDs, err } +// GetAll returns all users ordered by creation time. +func (s *UserStore) GetAll() ([]User, error) { + var users []User + err := s.db.Model(&User{}).Order("created_at").Find(&users).Error + return users, err +} + // UpdatePassword updates password func (s *UserStore) UpdatePassword(userID, passwordHash string) error { return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ diff --git a/telegram/agent/agent.go b/telegram/agent/agent.go index 550f95c7..8c4455d2 100644 --- a/telegram/agent/agent.go +++ b/telegram/agent/agent.go @@ -75,46 +75,107 @@ func GenerateBotToken(userID string) (string, error) { // and per-trader account summary + statistics) and returns it as a formatted string for // injection into the LLM context at the start of each conversation. func (a *Agent) buildAccountContext() string { - type q struct { - label string - path string - } - queries := []q{ - {"AI Models", "/api/models"}, - {"Exchanges", "/api/exchanges"}, - {"Strategies", "/api/strategies"}, - {"Traders", "/api/my-traders"}, - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID)) + sb.WriteString(fmt.Sprintf("[Current Account State — User: %s]\n\n", a.userID)) - var tradersJSON string - for _, query := range queries { - result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path}) - sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result)) - if query.path == "/api/my-traders" { - tradersJSON = result - } + // ── AI Models ───────────────────────────────────────────────────────────── + modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"}) + sb.WriteString("## AI Models\n") + sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n") + sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n") + + var models []struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` } + if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 { + for _, m := range models { + status := "disabled" + if m.Enabled { + status = "ENABLED" + } + sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status)) + } + } else { + sb.WriteString(modelsRaw) + } + sb.WriteString("\n") + + // ── Exchanges ───────────────────────────────────────────────────────────── + exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"}) + sb.WriteString("## Exchanges\n") + sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n") + + var exchanges []struct { + ID string `json:"id"` + Name string `json:"name"` + ExchangeType string `json:"exchange_type"` + AccountName string `json:"account_name"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 { + for _, e := range exchanges { + status := "disabled" + if e.Enabled { + status = "ENABLED" + } + sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status)) + } + } else { + sb.WriteString(exchangesRaw) + } + sb.WriteString("\n") + + // ── Strategies ──────────────────────────────────────────────────────────── + strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"}) + sb.WriteString("## Strategies\n") + + var strategies []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 { + for _, s := range strategies { + sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name)) + } + } else { + sb.WriteString(strategiesRaw) + } + sb.WriteString("\n") + + // ── Traders ─────────────────────────────────────────────────────────────── + tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"}) + sb.WriteString("## Traders\n") - // For each running trader, fetch real-time account balance and trading statistics. var traders []struct { TraderID string `json:"trader_id"` Name string `json:"trader_name"` IsRunning bool `json:"is_running"` } - if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil { + if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 { for _, t := range traders { - if !t.IsRunning { - continue + status := "stopped" + if t.IsRunning { + status = "RUNNING" } - acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID}) - sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct)) - - stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID}) - sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats)) + sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status)) } + } else { + sb.WriteString(tradersRaw) + } + sb.WriteString("\n") + + // ── Per-trader live data (running traders only) ──────────────────────────── + for _, t := range traders { + if !t.IsRunning { + continue + } + acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID}) + sb.WriteString(fmt.Sprintf("Account [%s]:\n%s\n\n", t.Name, acct)) + stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID}) + sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats)) } return sb.String() diff --git a/telegram/agent/manager.go b/telegram/agent/manager.go index e3acf34b..d461c28b 100644 --- a/telegram/agent/manager.go +++ b/telegram/agent/manager.go @@ -21,8 +21,9 @@ type Manager struct { } // NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs. -// userID is the database user ID the bot authenticates as (used in system prompt context). -func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager { +// userEmail is the registered email shown to the user when they ask "who am I". +// userID is the internal DB UUID used for API authentication. +func NewManager(apiPort int, botToken, userEmail, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager { return &Manager{ agents: make(map[int64]*Agent), lanes: make(map[int64]chan struct{}), @@ -30,7 +31,7 @@ func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient botToken: botToken, userID: userID, getLLM: getLLM, - systemPrompt: BuildAgentPrompt(apiDocs, userID), + systemPrompt: BuildAgentPrompt(apiDocs, userEmail, userID), } } diff --git a/telegram/agent/prompt.go b/telegram/agent/prompt.go index fb89e10a..c54bf5ee 100644 --- a/telegram/agent/prompt.go +++ b/telegram/agent/prompt.go @@ -4,14 +4,16 @@ import "fmt" // BuildAgentPrompt constructs the full system prompt with live API documentation injected. // apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas. -// userID is the actual database user ID the bot authenticates as. -func BuildAgentPrompt(apiDocs, userID string) string { +// userEmail is the registered email of the bound user (shown when user asks "who am I"). +// userID is the internal DB UUID used for API authentication only. +func BuildAgentPrompt(apiDocs, userEmail, userID string) string { return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant. ## Your Identity -- You are authenticated as user ID: %s +- You are operating as: %s +- Internal user ID (for API calls only): %s +- When asked "which user / account / email" — answer with the email address above - All API calls are made on behalf of this user -- When asked "which user / username / email" — answer with this user ID directly, no API call needed ## Tool: api_request Use the api_request tool to call the NOFX REST API: @@ -23,6 +25,16 @@ Use the api_request tool to call the NOFX REST API: %s +## CRITICAL: Exact ID Rule (read this before every API call) +API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value +from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute. + +Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id +Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models + +The Account State block at the start of this conversation lists every resource with its exact id. +Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess. + ## Behavior Rules 1. Reply in the same language the user used (中文→中文, English→English) 2. Keep final replies concise — show results, not process @@ -30,12 +42,6 @@ Use the api_request tool to call the NOFX REST API: 4. When user provides enough info, act immediately — no confirmation needed 5. Be decisive — infer intent from context, use schema to fill in smart defaults -## First-time Setup Detection -Check Account State at conversation start: -- If AI Models shows all disabled/unconfigured AND Exchanges empty → tell user to send /start for setup guide -- If Exchanges empty but models OK → guide user to configure exchange: ask for exchange type + API credentials in ONE message -- Never ask user to visit the web UI — everything can be done via chat - ## Verification Rule (CRITICAL) After ANY PUT or POST that creates or modifies a resource: 1. Immediately GET the resource to read actual saved values @@ -45,7 +51,7 @@ After ANY PUT or POST that creates or modifies a resource: ## Error Handling - 400: explain what was wrong, ask user to correct -- 404: resource doesn't exist, check IDs +- 404: resource doesn't exist — you may have used the wrong ID format; check the Account State for the exact id - "AI model not enabled": tell user to enable the model first via PUT /api/models - "Exchange not enabled": tell user to enable the exchange first - 5xx: server error, ask user to try again @@ -65,10 +71,6 @@ Use this to: ## Common Workflows -**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults). - -**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true. - **Create strategy** (independent from traders): - Never GET trader info just to create a strategy. - POST {"name":""} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable. @@ -91,5 +93,5 @@ Execute these steps IN ORDER with NO user confirmation between them: **Start/stop existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask. -**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs) +**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs) } diff --git a/telegram/bot.go b/telegram/bot.go index efdf32fa..f37dd29a 100644 --- a/telegram/bot.go +++ b/telegram/bot.go @@ -39,13 +39,13 @@ func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) { } } -// resolveToken returns the bot token, preferring the DB-stored value over the env/config value. +// resolveToken returns the bot token from DB (configured via Web UI). func resolveToken(cfg *config.Config, st *store.Store) string { dbCfg, err := st.TelegramConfig().Get() if err == nil && dbCfg.BotToken != "" { return dbCfg.BotToken } - return cfg.TelegramBotToken + return "" } // runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false). @@ -57,46 +57,46 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool { } logger.Infof("Telegram bot @%s started", bot.Self.UserName) - // Allowed chat ID: env override → DB-stored binding → 0 (unbound, first /start will bind). - allowedChatID := cfg.TelegramAdminChatID - if allowedChatID == 0 { - if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 { - allowedChatID = id - } + // Allowed chat ID: read from DB binding (0 = unbound, first /start will bind). + allowedChatID := int64(0) + if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 { + allowedChatID = id } // botUserID / botToken / agents are resolved lazily and refresh when user registers. var ( - botUserID string - botToken string - agents *agent.Manager + botUserID string + botUserEmail string + botToken string + agents *agent.Manager ) resolveBotUser := func() bool { - ids, err := st.User().GetAllIDs() - if err != nil || len(ids) == 0 { + users, err := st.User().GetAll() + if err != nil || len(users) == 0 { return false } - newID := ids[0] - if newID == botUserID { + u := users[0] + if u.ID == botUserID { return true } - newToken, err := agent.GenerateBotToken(newID) + newToken, err := agent.GenerateBotToken(u.ID) if err != nil { - logger.Errorf("Failed to generate bot JWT for user %s: %v", newID, err) + logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err) return false } prev := botUserID - botUserID = newID + botUserID = u.ID + botUserEmail = u.Email botToken = newToken - agents = agent.NewManager(cfg.APIServerPort, botToken, botUserID, + agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID, func() mcp.AIClient { return newLLMClient(st, botUserID) }, api.GetAPIDocs(), ) if prev == "" { - logger.Infof("Bot: resolved user %s", botUserID) + logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail) } else { - logger.Infof("Bot: user changed %s → %s", prev, botUserID) + logger.Infof("Bot: user changed → %s (%s)", botUserID, botUserEmail) } return true } diff --git a/web/src/App.tsx b/web/src/App.tsx index fb7fa602..240295a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,8 @@ import { TraderDashboardPage } from './pages/TraderDashboardPage' import { AITradersPage } from './components/AITradersPage' import { LoginPage } from './components/LoginPage' -import { RegisterPage } from './components/RegisterPage' +import { SetupPage } from './components/SetupPage' +import { SettingsPage } from './pages/SettingsPage' import { ResetPasswordPage } from './components/ResetPasswordPage' import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' @@ -53,7 +54,7 @@ type Page = function App() { const { language, setLanguage } = useLanguage() const { user, token, logout, isLoading } = useAuth() - const { loading: configLoading } = useSystemConfig() + const { config: systemConfig, loading: configLoading } = useSystemConfig() const [route, setRoute] = useState(window.location.pathname) // Debug log @@ -341,12 +342,22 @@ function App() { ) } + // First-time setup: redirect to /setup if system not initialized + if (systemConfig && !systemConfig.initialized && !user) { + return + } + // Handle specific routes regardless of authentication if (route === '/login') { return } - if (route === '/register') { - return + if (route === '/setup') { + // If already initialized, redirect to login + if (systemConfig?.initialized) { + window.location.href = '/login' + return null + } + return } if (route === '/faq') { return ( @@ -376,6 +387,26 @@ function App() { if (route === '/reset-password') { return } + if (route === '/settings') { + if (!user || !token) { + window.location.href = '/login' + return null + } + return ( +
+ + +
+ ) + } // Data page - publicly accessible with embedded dashboard if (route === '/data') { const dataPageNavigate = (page: Page) => { diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index bd5490ff..34038e5d 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -1499,7 +1499,7 @@ function ModelCard({ } // Model Configuration Modal Component -function ModelConfigModal({ +export function ModelConfigModal({ allModels, configuredModels, editingModelId, diff --git a/web/src/components/HeaderBar.tsx b/web/src/components/HeaderBar.tsx index bc7ebdd6..b5ef43f7 100644 --- a/web/src/components/HeaderBar.tsx +++ b/web/src/components/HeaderBar.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' -import { Menu, X, ChevronDown } from 'lucide-react' +import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { t, type Language } from '../i18n/translations' -import { useSystemConfig } from '../hooks/useSystemConfig' import { OFFICIAL_LINKS } from '../constants/branding' type Page = @@ -49,9 +48,6 @@ export default function HeaderBar({ const [userDropdownOpen, setUserDropdownOpen] = useState(false) const dropdownRef = useRef(null) const userDropdownRef = useRef(null) - const { config: systemConfig } = useSystemConfig() - const registrationEnabled = systemConfig?.registration_enabled !== false - // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -214,6 +210,16 @@ export default function HeaderBar({ {user.email} + {onLogout && ( - - - {/* Terminal Header */} -
-
-
-
- NoFx Logo -
-
-

- SYSTEM ACCESS -

-

- Authentication Protocol v3.0 -

-
- - {/* Terminal Output / Form Container */} -
-
- - {/* Window Bar */} -
-
-
window.location.href = '/'} - title="Close / Return Home" - >
-
-
-
-
- login.exe + {/* Logo + Title */} +
+
+
+
+ NOFX +
+

Welcome back

+

Sign in to your account

-
- {/* Status Output */} -
-
- - Initiating handshake... -
-
- - Target: NOFX CORE HUB -
-
- - Status: AWAITING CREDENTIALS -
-
+ {/* Card */} +
+
- {adminMode ? ( - -
- + {/* Email */} +
+ + setEmail(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="you@example.com" + required + autoFocus + /> +
+ + {/* Password */} +
+
+ + +
+
setAdminPassword(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono" - placeholder="ENTER_ROOT_PASSWORD" + type={showPassword ? 'text' : 'password'} + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="••••••••" required /> +
+
- {error && ( -
- [ERROR]: {error} -
- )} + {/* Error */} + {error && ( +

+ {error} +

+ )} - - - ) : ( -
-
-
- - setEmail(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono" - placeholder="user@nofx.os" - required - /> -
- -
-
- -
- -
- setPassword(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono pr-10" - placeholder="••••••••••••" - required - /> - -
-
- -
-
-
- - {error && ( -
- {error} -
- )} - - -
- )} -
- - {/* Terminal Footer Info */} -
-
SECURE_CONNECTION: ENCRYPTED
-
{new Date().toISOString().split('T')[0]}
-
-
- - {/* Register Link */} - {!adminMode && registrationEnabled && ( -
-

- NEW_USER_DETECTED?{' '} + {/* Submit */} -

- +
- )} + +
) diff --git a/web/src/components/RegisterPage.tsx b/web/src/components/RegisterPage.tsx index 55182bac..7485501b 100644 --- a/web/src/components/RegisterPage.tsx +++ b/web/src/components/RegisterPage.tsx @@ -30,7 +30,7 @@ export function RegisterPage() { getSystemConfig() .then((config) => { setBetaMode(config.beta_mode || false) - setRegistrationEnabled(config.registration_enabled !== false) + setRegistrationEnabled(config.initialized === false) }) .catch((err) => { console.error('Failed to fetch system config:', err) diff --git a/web/src/components/SetupPage.tsx b/web/src/components/SetupPage.tsx new file mode 100644 index 00000000..5ad87df7 --- /dev/null +++ b/web/src/components/SetupPage.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { DeepVoidBackground } from './DeepVoidBackground' +import { invalidateSystemConfig } from '../lib/config' + +export function SetupPage() { + const { register } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + if (password.length < 8) { + setError('Password must be at least 8 characters') + return + } + setLoading(true) + const result = await register(email, password) + setLoading(false) + if (result.success) { + invalidateSystemConfig() + window.location.href = '/traders' + } else { + setError(result.message || 'Setup failed, please try again') + } + } + + return ( + +
+
+ + {/* Logo + Title */} +
+
+
+
+ NOFX +
+
+

Welcome to NOFX

+

Create your account to get started

+
+ + {/* Card */} +
+
+ + {/* Email */} +
+ + setEmail(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="you@example.com" + required + autoFocus + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="At least 8 characters" + required + /> + +
+
+ + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Submit */} + +
+
+ +

+ Single-user system — this is the only account +

+
+
+ + ) +} diff --git a/web/src/components/landing/LoginModal.tsx b/web/src/components/landing/LoginModal.tsx index 3abaf1b8..abb9e3bc 100644 --- a/web/src/components/landing/LoginModal.tsx +++ b/web/src/components/landing/LoginModal.tsx @@ -1,16 +1,12 @@ import { motion } from 'framer-motion' import { X } from 'lucide-react' import { t, Language } from '../../i18n/translations' -import { useSystemConfig } from '../../hooks/useSystemConfig' - interface LoginModalProps { onClose: () => void language: Language } export default function LoginModal({ onClose, language }: LoginModalProps) { - const { config: systemConfig } = useSystemConfig() - const registrationEnabled = systemConfig?.registration_enabled !== false return ( {t('signIn', language)} - {registrationEnabled && ( - { - window.history.pushState({}, '', '/register') - window.dispatchEvent(new PopStateEvent('popstate')) - onClose() - }} - className="block w-full px-6 py-3 rounded-lg font-semibold text-center" - style={{ - background: 'var(--brand-dark-gray)', - color: 'var(--brand-light-gray)', - border: '1px solid rgba(240, 185, 11, 0.2)', - }} - whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }} - whileTap={{ scale: 0.95 }} - > - {t('registerNewAccount', language)} - - )}
diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 335aacd0..54d138e0 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -1,6 +1,6 @@ export interface SystemConfig { - beta_mode: boolean - registration_enabled?: boolean + initialized: boolean + beta_mode?: boolean } let configPromise: Promise | null = null @@ -19,8 +19,11 @@ export function getSystemConfig(): Promise { cachedConfig = data return data }) - .finally(() => { - // Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later - }) return configPromise } + +/** Call after first-time setup completes so next check reflects initialized=true */ +export function invalidateSystemConfig() { + cachedConfig = null + configPromise = null +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..0ad9a875 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,489 @@ +import { useState, useEffect } from 'react' +import { toast } from 'sonner' +import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' +import { api } from '../lib/api' +import { ExchangeConfigModal } from '../components/traders/ExchangeConfigModal' +import { TelegramConfigModal } from '../components/traders/TelegramConfigModal' +import { ModelConfigModal } from '../components/AITradersPage' +import type { Exchange, AIModel } from '../types' + +type Tab = 'account' | 'models' | 'exchanges' | 'telegram' + +export function SettingsPage() { + const { user } = useAuth() + const { language } = useLanguage() + const [activeTab, setActiveTab] = useState('account') + + // Account state + const [newPassword, setNewPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [changingPassword, setChangingPassword] = useState(false) + + // AI Models state + const [configuredModels, setConfiguredModels] = useState([]) + const [supportedModels, setSupportedModels] = useState([]) + const [showModelModal, setShowModelModal] = useState(false) + const [editingModel, setEditingModel] = useState(null) + + // Exchanges state + const [exchanges, setExchanges] = useState([]) + const [showExchangeModal, setShowExchangeModal] = useState(false) + const [editingExchange, setEditingExchange] = useState(null) + + // Telegram state + const [showTelegramModal, setShowTelegramModal] = useState(false) + + // Fetch data when tabs are visited + useEffect(() => { + if (activeTab === 'models') { + Promise.all([api.getModelConfigs(), api.getSupportedModels()]) + .then(([configs, supported]) => { + setConfiguredModels(configs) + setSupportedModels(supported) + }) + .catch(() => toast.error('Failed to load AI models')) + } + if (activeTab === 'exchanges') { + api.getExchangeConfigs() + .then(setExchanges) + .catch(() => toast.error('Failed to load exchanges')) + } + }, [activeTab]) + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + if (newPassword.length < 8) { + toast.error('Password must be at least 8 characters') + return + } + setChangingPassword(true) + try { + const res = await fetch('/api/user/password', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ new_password: newPassword }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to update password') + } + toast.success('Password updated successfully') + setNewPassword('') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update password') + } finally { + setChangingPassword(false) + } + } + + const handleSaveModel = async ( + modelId: string, + apiKey: string, + customApiUrl?: string, + customModelName?: string + ) => { + try { + const existingModel = configuredModels.find((m) => m.id === modelId) + const modelTemplate = supportedModels.find((m) => m.id === modelId) + const modelToUpdate = existingModel || modelTemplate + if (!modelToUpdate) { toast.error('Model not found'); return } + + let updatedModels: AIModel[] + if (existingModel) { + updatedModels = configuredModels.map((m) => + m.id === modelId + ? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true } + : m + ) + } else { + updatedModels = [...configuredModels, { + ...modelToUpdate, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + }] + } + + const request = { + models: Object.fromEntries( + updatedModels.map((m) => [m.provider, { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }]) + ), + } + await toast.promise(api.updateModelConfigs(request), { + loading: 'Saving model config...', + success: 'Model config saved', + error: 'Failed to save model config', + }) + const refreshed = await api.getModelConfigs() + setConfiguredModels(refreshed) + setShowModelModal(false) + setEditingModel(null) + } catch { + toast.error('Failed to save model config') + } + } + + const handleDeleteModel = async (modelId: string) => { + try { + const updatedModels = configuredModels.map((m) => + m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m + ) + const request = { + models: Object.fromEntries( + updatedModels.map((m) => [m.provider, { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }]) + ), + } + await api.updateModelConfigs(request) + const refreshed = await api.getModelConfigs() + setConfiguredModels(refreshed) + setShowModelModal(false) + setEditingModel(null) + toast.success('Model config removed') + } catch { + toast.error('Failed to remove model config') + } + } + + const handleSaveExchange = async ( + exchangeId: string | null, + exchangeType: string, + accountName: string, + apiKey: string, + secretKey?: string, + passphrase?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string, + lighterWalletAddr?: string, + lighterPrivateKey?: string, + lighterApiKeyPrivateKey?: string, + lighterApiKeyIndex?: number + ) => { + try { + if (exchangeId) { + const request = { + exchanges: { + [exchangeId]: { + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', + lighter_api_key_index: lighterApiKeyIndex || 0, + }, + }, + } + await toast.promise(api.updateExchangeConfigsEncrypted(request), { + loading: 'Updating exchange config...', + success: 'Exchange config updated', + error: 'Failed to update exchange config', + }) + } else { + const createRequest = { + exchange_type: exchangeType, + account_name: accountName, + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', + lighter_api_key_index: lighterApiKeyIndex || 0, + } + await toast.promise(api.createExchangeEncrypted(createRequest), { + loading: 'Creating exchange account...', + success: 'Exchange account created', + error: 'Failed to create exchange account', + }) + } + const refreshed = await api.getExchangeConfigs() + setExchanges(refreshed) + setShowExchangeModal(false) + setEditingExchange(null) + } catch { + toast.error('Failed to save exchange config') + } + } + + const handleDeleteExchange = async (exchangeId: string) => { + try { + await toast.promise(api.deleteExchange(exchangeId), { + loading: 'Deleting exchange account...', + success: 'Exchange account deleted', + error: 'Failed to delete exchange account', + }) + const refreshed = await api.getExchangeConfigs() + setExchanges(refreshed) + setShowExchangeModal(false) + setEditingExchange(null) + } catch { + toast.error('Failed to delete exchange account') + } + } + + const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ + { key: 'account', label: 'Account', icon: }, + { key: 'models', label: 'AI Models', icon: }, + { key: 'exchanges', label: 'Exchanges', icon: }, + { key: 'telegram', label: 'Telegram', icon: }, + ] + + return ( +
+
+

Settings

+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ + {/* Account Tab */} + {activeTab === 'account' && ( +
+
+

Email

+

{user?.email}

+
+ +
+

Change Password

+
+
+ +
+ setNewPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="At least 8 characters" + required + /> + +
+
+ +
+
+
+ )} + + {/* AI Models Tab */} + {activeTab === 'models' && ( +
+
+

+ {configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured +

+ +
+ + {configuredModels.length === 0 ? ( +
+ No AI models configured yet +
+ ) : ( +
+ {configuredModels.map((model) => ( + + ))} +
+ )} +
+ )} + + {/* Exchanges Tab */} + {activeTab === 'exchanges' && ( +
+
+

+ {exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected +

+ +
+ + {exchanges.length === 0 ? ( +
+ No exchange accounts connected yet +
+ ) : ( +
+ {exchanges.map((exchange) => ( + + ))} +
+ )} +
+ )} + + {/* Telegram Tab */} + {activeTab === 'telegram' && ( +
+

+ Connect a Telegram bot to receive trading notifications and interact with your traders. +

+ +
+ )} +
+
+ + {/* AI Model Modal */} + {showModelModal && ( +
+ { setShowModelModal(false); setEditingModel(null) }} + language={language} + /> +
+ )} + + {/* Exchange Modal */} + {showExchangeModal && ( +
+ { setShowExchangeModal(false); setEditingExchange(null) }} + language={language} + /> +
+ )} + + {/* Telegram Modal */} + {showTelegramModal && ( +
+ setShowTelegramModal(false)} + language={language} + /> +
+ )} +
+ ) +}