This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/admin.go
2026-02-21 21:59:38 +00:00

795 lines
22 KiB
Go

package handlers
import (
"encoding/json"
"math"
"net/http"
"strconv"
"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,
"branding": 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) UpdateGlobalConfig(c *gin.Context) {
configID := c.Param("id")
var req struct {
Name *string `json:"name"`
Endpoint *string `json:"endpoint"`
APIKey *string `json:"api_key"`
ModelDefault *string `json:"model_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sets := []string{}
args := []interface{}{}
argN := 1
add := func(col string, val interface{}) {
sets = append(sets, col+" = $"+strconv.Itoa(argN))
args = append(args, val)
argN++
}
if req.Name != nil { add("name", *req.Name) }
if req.Endpoint != nil { add("endpoint", *req.Endpoint) }
if req.APIKey != nil && *req.APIKey != "" { add("api_key_encrypted", *req.APIKey) }
if req.ModelDefault != nil { add("model_default", *req.ModelDefault) }
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, configID)
query := "UPDATE api_configs SET " + strings.Join(sets, ", ") + " WHERE id = $" + strconv.Itoa(argN) + " AND user_id IS NULL"
result, err := database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update 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{"status": "updated"})
}
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"})
}