package handlers import ( "encoding/json" "math" "net/http" "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" ) // ── Types ─────────────────────────────────── type adminUserResponse struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` DisplayName *string `json:"display_name"` Role string `json:"role"` IsActive bool `json:"is_active"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LastLoginAt *string `json:"last_login_at"` } type adminCreateUserRequest struct { Username string `json:"username" binding:"required,min=3,max=50"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8,max=128"` Role string `json:"role" binding:"required,oneof=user admin moderator"` } type adminResetPasswordRequest struct { NewPassword string `json:"new_password" binding:"required,min=8,max=128"` } type updateUserRoleRequest struct { Role string `json:"role" binding:"required,oneof=user admin moderator"` } type updateUserActiveRequest struct { IsActive bool `json:"is_active"` } type globalSettingResponse struct { Key string `json:"key"` Value map[string]interface{} `json:"value"` UpdatedAt string `json:"updated_at"` } // AdminHandler manages admin-only operations. type AdminHandler struct{} // NewAdminHandler creates a new handler. func NewAdminHandler() *AdminHandler { return &AdminHandler{} } // ── List Users ────────────────────────────── func (h *AdminHandler) ListUsers(c *gin.Context) { page, perPage, offset := parsePagination(c) var total int if err := database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&total); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"}) return } rows, err := database.DB.Query(` SELECT id, username, email, display_name, role, is_active, created_at, updated_at, last_login_at FROM users ORDER BY created_at ASC LIMIT $1 OFFSET $2 `, perPage, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) return } defer rows.Close() users := make([]adminUserResponse, 0) for rows.Next() { var u adminUserResponse if err := rows.Scan( &u.ID, &u.Username, &u.Email, &u.DisplayName, &u.Role, &u.IsActive, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, ); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan user"}) return } users = append(users, u) } c.JSON(http.StatusOK, paginatedResponse{ Data: users, Page: page, PerPage: perPage, Total: total, TotalPages: int(math.Ceil(float64(total) / float64(perPage))), }) } // ── Create User (admin) ───────────────────── func (h *AdminHandler) CreateUser(c *gin.Context) { var req adminCreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Username = strings.TrimSpace(req.Username) hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } var user adminUserResponse err = database.DB.QueryRow(` INSERT INTO users (username, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, role, is_active, created_at, updated_at, last_login_at `, req.Username, req.Email, string(hash), req.Role).Scan( &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.IsActive, &user.CreatedAt, &user.UpdatedAt, &user.LastLoginAt, ) if err != nil { if strings.Contains(err.Error(), "duplicate key") { field := "email" if strings.Contains(err.Error(), "username") { field = "username" } c.JSON(http.StatusConflict, gin.H{"error": field + " already taken"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) return } c.JSON(http.StatusCreated, user) } // ── Reset Password (admin) ────────────────── func (h *AdminHandler) ResetPassword(c *gin.Context) { targetID := c.Param("id") var req adminResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } result, err := database.DB.Exec( `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(hash), targetID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "password reset"}) } // ── Update User Role ──────────────────────── func (h *AdminHandler) UpdateUserRole(c *gin.Context) { targetID := c.Param("id") adminID := getUserID(c) var req updateUserRoleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Prevent admin from demoting themselves if targetID == adminID && req.Role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "cannot change your own role"}) return } result, err := database.DB.Exec( `UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2`, req.Role, targetID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "role updated", "role": req.Role}) } // ── Toggle User Active ────────────────────── func (h *AdminHandler) ToggleUserActive(c *gin.Context) { targetID := c.Param("id") adminID := getUserID(c) var req updateUserActiveRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Prevent admin from deactivating themselves if targetID == adminID && !req.IsActive { c.JSON(http.StatusForbidden, gin.H{"error": "cannot deactivate your own account"}) return } result, err := database.DB.Exec( `UPDATE users SET is_active = $1, updated_at = NOW() WHERE id = $2`, req.IsActive, targetID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "user updated", "is_active": req.IsActive}) } // ── Delete User ───────────────────────────── func (h *AdminHandler) DeleteUser(c *gin.Context) { targetID := c.Param("id") adminID := getUserID(c) if targetID == adminID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete your own account"}) return } result, err := database.DB.Exec(`DELETE FROM users WHERE id = $1`, targetID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) } // ── Public Settings (for any authenticated user) ── var publicSettingKeys = map[string]bool{ "banner": true, "user_providers_enabled": true, "registration_enabled": true, "registration_default_state": true, "banner_presets": true, } func (h *AdminHandler) PublicSettings(c *gin.Context) { rows, err := database.DB.Query(` SELECT key, value::text FROM global_settings ORDER BY key `) if err != nil { c.JSON(http.StatusOK, gin.H{"settings": []interface{}{}}) return } defer rows.Close() settings := make([]globalSettingResponse, 0) for rows.Next() { var key, valueRaw string if err := rows.Scan(&key, &valueRaw); err != nil { continue } if !publicSettingKeys[key] { continue } s := globalSettingResponse{Key: key} s.Value = make(map[string]interface{}) _ = json.Unmarshal([]byte(valueRaw), &s.Value) settings = append(settings, s) } c.JSON(http.StatusOK, gin.H{"settings": settings}) } // ── List Global Settings ──────────────────── func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { rows, err := database.DB.Query(` SELECT key, value::text, updated_at FROM global_settings ORDER BY key `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list settings"}) return } defer rows.Close() settings := make([]globalSettingResponse, 0) for rows.Next() { var s globalSettingResponse var valueRaw string if err := rows.Scan(&s.Key, &valueRaw, &s.UpdatedAt); err != nil { continue } s.Value = make(map[string]interface{}) _ = json.Unmarshal([]byte(valueRaw), &s.Value) settings = append(settings, s) } c.JSON(http.StatusOK, gin.H{"settings": settings}) } // ── Get Global Setting ────────────────────── func (h *AdminHandler) GetGlobalSetting(c *gin.Context) { key := c.Param("key") var valueRaw, updatedAt string err := database.DB.QueryRow(` SELECT value::text, updated_at FROM global_settings WHERE key = $1 `, key).Scan(&valueRaw, &updatedAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) return } value := make(map[string]interface{}) _ = json.Unmarshal([]byte(valueRaw), &value) c.JSON(http.StatusOK, globalSettingResponse{ Key: key, Value: value, UpdatedAt: updatedAt, }) } // ── Update Global Setting ─────────────────── func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { key := c.Param("key") adminID := getUserID(c) var value map[string]interface{} if err := c.ShouldBindJSON(&value); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } valueJSON, err := json.Marshal(value) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid value"}) return } _, err = database.DB.Exec(` INSERT INTO global_settings (key, value, updated_at, updated_by) VALUES ($1, $2::jsonb, NOW(), $3) ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = NOW(), updated_by = $3 `, key, string(valueJSON), adminID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"}) return } h.GetGlobalSetting(c) } // ── Admin Stats ───────────────────────────── func (h *AdminHandler) GetStats(c *gin.Context) { stats := make(map[string]int) queries := map[string]string{ "total_users": "SELECT COUNT(*) FROM users", "active_users": "SELECT COUNT(*) FROM users WHERE is_active = true", "total_channels": "SELECT COUNT(*) FROM channels", "total_messages": "SELECT COUNT(*) FROM messages", "api_configs": "SELECT COUNT(*) FROM api_configs", } for key, query := range queries { var count int if err := database.DB.QueryRow(query).Scan(&count); err != nil { stats[key] = 0 } else { stats[key] = count } } c.JSON(http.StatusOK, stats) } // ── Global API Configs ────────────────────── type adminGlobalConfigResponse struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` Endpoint string `json:"endpoint"` ModelDefault *string `json:"model_default"` IsActive bool `json:"is_active"` HasKey bool `json:"has_key"` CreatedAt string `json:"created_at"` } func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { rows, err := database.DB.Query(` SELECT id, name, provider, endpoint, model_default, is_active, (api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key, created_at FROM api_configs WHERE user_id IS NULL ORDER BY created_at ASC `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } defer rows.Close() configs := make([]adminGlobalConfigResponse, 0) for rows.Next() { var cfg adminGlobalConfigResponse if err := rows.Scan( &cfg.ID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &cfg.ModelDefault, &cfg.IsActive, &cfg.HasKey, &cfg.CreatedAt, ); err != nil { continue } configs = append(configs, cfg) } c.JSON(http.StatusOK, gin.H{"configs": configs}) } func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { var req createAPIConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var id string err := database.DB.QueryRow(` INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global) VALUES ($1, $2, $3, $4, $5, NULL, true) RETURNING id `, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) return } c.JSON(http.StatusCreated, gin.H{"id": id, "message": "global config created"}) } func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { configID := c.Param("id") result, err := database.DB.Exec( `DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL`, configID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "global config not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "global config deleted"}) } // ── Model Configs ─────────────────────────── type modelConfigResponse struct { ID string `json:"id"` APIConfigID string `json:"api_config_id"` ProviderName string `json:"provider_name"` ModelID string `json:"model_id"` DisplayName *string `json:"display_name"` IsEnabled bool `json:"is_enabled"` Capabilities map[string]interface{} `json:"capabilities"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type updateModelConfigRequest struct { IsEnabled *bool `json:"is_enabled"` DisplayName *string `json:"display_name"` Capabilities map[string]interface{} `json:"capabilities"` } func (h *AdminHandler) ListModelConfigs(c *gin.Context) { rows, err := database.DB.Query(` SELECT mc.id, mc.api_config_id, ac.name, mc.model_id, mc.display_name, mc.is_enabled, mc.capabilities, mc.created_at, mc.updated_at FROM model_configs mc JOIN api_configs ac ON mc.api_config_id = ac.id WHERE ac.user_id IS NULL ORDER BY ac.name, mc.model_id `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) return } defer rows.Close() models := make([]modelConfigResponse, 0) for rows.Next() { var m modelConfigResponse var capsJSON []byte if err := rows.Scan( &m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName, &m.IsEnabled, &capsJSON, &m.CreatedAt, &m.UpdatedAt, ); err != nil { continue } _ = json.Unmarshal(capsJSON, &m.Capabilities) models = append(models, m) } c.JSON(http.StatusOK, gin.H{"models": models}) } func (h *AdminHandler) FetchModels(c *gin.Context) { // Load all global api_configs rows, err := database.DB.Query(` SELECT id, provider, endpoint, api_key_encrypted, custom_headers FROM api_configs WHERE user_id IS NULL AND is_active = true `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } defer rows.Close() type fetchResult struct { ConfigID string `json:"config_id"` Provider string `json:"provider"` Added int `json:"added"` Updated int `json:"updated"` Skipped int `json:"skipped"` Error string `json:"error,omitempty"` } results := make([]fetchResult, 0) totalAdded := 0 for rows.Next() { var cfgID, providerID, endpoint string var apiKey *string var customHeadersJSON []byte if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey, &customHeadersJSON); err != nil { continue } prov, err := providers.Get(providerID) if err != nil { results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()}) continue } key := "" if apiKey != nil { key = *apiKey } customHeaders := make(map[string]string) if customHeadersJSON != nil { _ = json.Unmarshal(customHeadersJSON, &customHeaders) } models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{ Endpoint: endpoint, APIKey: key, CustomHeaders: customHeaders, }) if err != nil { results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()}) continue } fr := fetchResult{ConfigID: cfgID, Provider: providerID} for _, m := range models { // Serialize capabilities to JSONB capsJSON, _ := json.Marshal(m.Capabilities) result, err := database.DB.Exec(` INSERT INTO model_configs (api_config_id, model_id, display_name, capabilities) VALUES ($1, $2, $3, $4) ON CONFLICT (api_config_id, model_id) DO UPDATE SET display_name = COALESCE(NULLIF(model_configs.display_name, ''), EXCLUDED.display_name), capabilities = EXCLUDED.capabilities, updated_at = NOW() `, cfgID, m.ID, m.Name, capsJSON) if err != nil { fr.Skipped++ continue } affected, _ := result.RowsAffected() if affected > 0 { fr.Added++ totalAdded++ } else { fr.Skipped++ } } results = append(results, fr) } c.JSON(http.StatusOK, gin.H{ "total_added": totalAdded, "results": results, }) } func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { modelID := c.Param("id") var req updateModelConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build dynamic update if req.IsEnabled != nil { _, err := database.DB.Exec( `UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`, *req.IsEnabled, modelID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled"}) return } } if req.DisplayName != nil { _, err := database.DB.Exec( `UPDATE model_configs SET display_name = $1, updated_at = NOW() WHERE id = $2`, *req.DisplayName, modelID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update name"}) return } } if req.Capabilities != nil { capsJSON, err := json.Marshal(req.Capabilities) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capabilities"}) return } _, err = database.DB.Exec( `UPDATE model_configs SET capabilities = $1, updated_at = NOW() WHERE id = $2`, string(capsJSON), modelID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update capabilities"}) return } } c.JSON(http.StatusOK, gin.H{"message": "model updated"}) } // BulkUpdateModels enables or disables all models at once func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { var req struct { IsEnabled bool `json:"is_enabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := database.DB.Exec( `UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`, req.IsEnabled, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"}) return } rows, _ := result.RowsAffected() c.JSON(http.StatusOK, gin.H{"message": "models updated", "count": rows}) } func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { modelID := c.Param("id") result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) }