diff --git a/admin.go b/admin.go deleted file mode 100644 index c08d2d6..0000000 --- a/admin.go +++ /dev/null @@ -1,668 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" - - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/store" -) - -type AdminHandler struct { - stores store.Stores - vault *crypto.KeyResolver - uekCache *crypto.UEKCache -} - -func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler { - return &AdminHandler{stores: s, vault: vault, uekCache: uekCache} -} - -// ── User Management ───────────────────────── - -func (h *AdminHandler) ListUsers(c *gin.Context) { - opts := store.DefaultListOptions() - - // Accept both limit/offset and page/per_page conventions - if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 { - opts.Limit = limit - } - if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 { - opts.Offset = offset - } - if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 { - opts.Limit = perPage - } - if page, _ := strconv.Atoi(c.Query("page")); page > 1 { - opts.Offset = (page - 1) * opts.Limit - } - - users, total, err := h.stores.Users.List(c.Request.Context(), opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) - return - } - - c.JSON(http.StatusOK, gin.H{"users": users, "total": total}) -} - -func (h *AdminHandler) CreateUser(c *gin.Context) { - var req struct { - Username string `json:"username" binding:"required"` - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required,min=8"` - Role string `json:"role"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) - role := models.UserRoleUser - if req.Role == models.UserRoleAdmin { - role = models.UserRoleAdmin - } - - user := &models.User{ - Username: strings.ToLower(req.Username), - Email: strings.ToLower(req.Email), - PasswordHash: string(hash), - Role: role, - IsActive: true, - } - - if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) - return - } - - h.auditLog(c, "user.create", "user", user.ID, nil) - c.JSON(http.StatusCreated, user) -} - -func (h *AdminHandler) UpdateUserRole(c *gin.Context) { - id := c.Param("id") - var req struct { - Role string `json:"role" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) - return - } - - h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) - c.JSON(http.StatusOK, gin.H{"message": "role updated"}) -} - -func (h *AdminHandler) ToggleUserActive(c *gin.Context) { - id := c.Param("id") - var req struct { - IsActive bool `json:"is_active"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) - return - } - - h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive}) - c.JSON(http.StatusOK, gin.H{"message": "user status updated"}) -} - -func (h *AdminHandler) ResetPassword(c *gin.Context) { - id := c.Param("id") - var req struct { - Password string `json:"password" binding:"required,min=8"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) - if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) - return - } - - // Destroy vault — old UEK is unrecoverable with new password. - // A fresh vault will be initialized on the user's next login. - h.destroyVault(c, id) - - h.auditLog(c, "user.password_reset", "user", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "password reset"}) -} - -// destroyVault nullifies a user's encrypted UEK and deletes their personal -// provider keys. Called when an admin resets a user's password — the old -// UEK cannot be recovered without the old password. -func (h *AdminHandler) destroyVault(c *gin.Context, userID string) { - if h.uekCache == nil { - return - } - - // Null out vault columns — user gets a fresh vault on next login - _, err := database.DB.Exec(` - UPDATE users - SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false - WHERE id = $1 - `, userID) - if err != nil { - log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err) - } - - // Evict from session cache (if user is currently logged in) - h.uekCache.Evict(userID) - - // Delete personal provider configs — their keys are undecryptable now - result, err := database.DB.Exec(` - DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1 - `, userID) - if err != nil { - log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err) - } else if rows, _ := result.RowsAffected(); rows > 0 { - log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID) - } - - h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{ - "reason": "admin_password_reset", - }) -} - -func (h *AdminHandler) DeleteUser(c *gin.Context) { - id := c.Param("id") - if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) - return - } - h.auditLog(c, "user.delete", "user", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) -} - -// ── Global Settings ───────────────────────── - -func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { - settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) - return - } - // Also include policies - policies, _ := h.stores.Policies.GetAll(c.Request.Context()) - c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies}) -} - -func (h *AdminHandler) GetGlobalSetting(c *gin.Context) { - key := c.Param("key") - val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"key": key, "value": val}) -} - -func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { - key := c.Param("key") - userID, _ := c.Get("user_id") - uid := userID.(string) - - var req struct { - Value json.RawMessage `json:"value"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Determine if this is a policy (string value) or a global config (JSON value) - var strVal string - if err := json.Unmarshal(req.Value, &strVal); err == nil { - // String value → try as policy first - if _, ok := models.PolicyDefaults[key]; ok { - if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"}) - return - } - h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal}) - c.JSON(http.StatusOK, gin.H{"message": "policy updated"}) - return - } - } - - // JSON value → global config - var jsonVal models.JSONMap - json.Unmarshal(req.Value, &jsonVal) - if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"}) - return - } - - h.auditLog(c, "settings.update", "global_settings", key, nil) - c.JSON(http.StatusOK, gin.H{"message": "setting updated"}) -} - -func (h *AdminHandler) PublicSettings(c *gin.Context) { - // Banner config, branding, etc. — safe subset for non-admin users - banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner") - branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding") - systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt") - policies, _ := h.stores.Policies.GetAll(c.Request.Context()) - - // Only tell the user whether admin prompt exists — don't expose content - hasAdminPrompt := false - if content, ok := systemPrompt["content"].(string); ok && content != "" { - hasAdminPrompt = true - } - - c.JSON(http.StatusOK, gin.H{ - "banner": banner, - "branding": branding, - "has_admin_prompt": hasAdminPrompt, - "storage_configured": storageConfigured, - "policies": gin.H{ - "allow_registration": policies["allow_registration"], - "allow_user_byok": policies["allow_user_byok"], - "allow_user_personas": policies["allow_user_personas"], - }, - }) -} - -// ── Vault Status ──────────────────────────── - -func (h *AdminHandler) VaultStatus(c *gin.Context) { - hasKey := h.vault != nil && h.vault.HasEnvKey() - keyStr := "" - if hasKey { - keyStr = "set" // non-empty signals to VaultStatus that key is configured - } - status, err := crypto.VaultStatus(database.DB, keyStr) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"}) - return - } - c.JSON(http.StatusOK, status) -} - -// ── Provider Configs (Global) ─────────────── - -func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { - cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) - return - } - - // Redact API keys but expose has_key flag for UI - type configWithKey struct { - models.ProviderConfig - HasKey bool `json:"has_key"` - } - out := make([]configWithKey, len(cfgs)) - for i, cfg := range cfgs { - out[i] = configWithKey{ - ProviderConfig: cfg, - HasKey: cfg.HasKey(), - } - } - c.JSON(http.StatusOK, gin.H{"configs": out}) -} - -func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { - // Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-" - // so ShouldBindJSON would silently drop the api_key field. - var req struct { - Name string `json:"name" binding:"required"` - Provider string `json:"provider" binding:"required"` - Endpoint string `json:"endpoint" binding:"required"` - APIKey string `json:"api_key"` - ModelDefault string `json:"model_default,omitempty"` - Config map[string]interface{} `json:"config,omitempty"` - Headers map[string]interface{} `json:"headers,omitempty"` - Settings map[string]interface{} `json:"settings,omitempty"` - IsPrivate bool `json:"is_private,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if _, err := providers.Get(req.Provider); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "unsupported provider: " + req.Provider, - "supported_providers": providers.List(), - }) - return - } - - cfg := &models.ProviderConfig{ - Name: req.Name, - Provider: req.Provider, - Endpoint: req.Endpoint, - ModelDefault: req.ModelDefault, - Config: models.JSONMap(req.Config), - Headers: models.JSONMap(req.Headers), - Settings: models.JSONMap(req.Settings), - Scope: models.ScopeGlobal, - KeyScope: models.ScopeGlobal, - IsActive: true, - IsPrivate: req.IsPrivate, - } - - // Encrypt the API key - if req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - cfg.APIKeyEnc = enc - cfg.KeyNonce = nonce - } else { - // No vault configured — store raw bytes (unencrypted fallback) - cfg.APIKeyEnc = []byte(req.APIKey) - } - } - - if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) - return - } - - h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider}) - c.JSON(http.StatusCreated, gin.H{ - "id": cfg.ID, - "scope": cfg.Scope, - "name": cfg.Name, - "provider": cfg.Provider, - "endpoint": cfg.Endpoint, - "model_default": cfg.ModelDefault, - "config": cfg.Config, - "headers": cfg.Headers, - "settings": cfg.Settings, - "is_active": cfg.IsActive, - "is_private": cfg.IsPrivate, - "has_key": cfg.HasKey(), - "created_at": cfg.CreatedAt, - "updated_at": cfg.UpdatedAt, - }) -} - -func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { - id := c.Param("id") - - // Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-" - var req struct { - models.ProviderConfigPatch - APIKey *string `json:"api_key,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - patch := req.ProviderConfigPatch - // Transfer api_key → encrypted fields - if req.APIKey != nil && *req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - patch.APIKeyEnc = enc - patch.KeyNonce = nonce - } else { - patch.APIKeyEnc = []byte(*req.APIKey) - } - } - - if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) - return - } - - h.auditLog(c, "provider.update", "provider_config", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "config updated"}) -} - -func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { - id := c.Param("id") - // Delete associated catalog entries first - h.stores.Catalog.DeleteForProvider(c.Request.Context(), id) - if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) - return - } - h.auditLog(c, "provider.delete", "provider_config", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) -} - -// ── Model Catalog ─────────────────────────── - -func (h *AdminHandler) ListModelConfigs(c *gin.Context) { - entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) - return - } - c.JSON(http.StatusOK, gin.H{"models": entries}) -} - -func (h *AdminHandler) FetchModels(c *gin.Context) { - var req struct { - ProviderConfigID string `json:"provider_config_id"` - } - c.ShouldBindJSON(&req) - - // If no specific provider, fetch from ALL global providers - if req.ProviderConfigID == "" { - configs, err := h.stores.Providers.ListGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"}) - return - } - if len(configs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"}) - return - } - - totalAdded, totalUpdated, totalFetched := 0, 0, 0 - var errs []string - for _, cfg := range configs { - if !cfg.IsActive { - continue - } - added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg) - if err != nil { - errs = append(errs, cfg.Provider+": "+err.Error()) - continue - } - totalAdded += added - totalUpdated += updated - totalFetched += fetched - } - - result := gin.H{ - "message": "models synced", - "added": totalAdded, - "updated": totalUpdated, - "total": totalFetched, - } - if len(errs) > 0 { - result["errors"] = errs - } - c.JSON(http.StatusOK, result) - return - } - - // Single provider fetch - cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"}) - return - } - - added, updated, fetched, err := h.fetchModelsForProvider(c, cfg) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "models synced", - "added": added, - "updated": updated, - "total": fetched, - }) -} - -// fetchModelsForProvider fetches and syncs models for a single provider config. -func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) { - // Decrypt the API key for the provider API call - apiKey := "" - if len(cfg.APIKeyEnc) > 0 { - if h.vault != nil { - apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") - if err != nil { - return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err) - } - } else { - // No vault — key stored as raw bytes (unencrypted fallback) - apiKey = string(cfg.APIKeyEnc) - } - } - - result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey) - if err != nil { - return 0, 0, 0, err - } - - h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{ - "added": result.Added, "updated": result.Updated, "total": result.Total, - }) - - return result.Added, result.Updated, result.Total, nil -} - -func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { - id := c.Param("id") - var req struct { - Visibility *string `json:"visibility"` - DisplayName *string `json:"display_name"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Visibility != nil { - if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"}) - return - } - } - - c.JSON(http.StatusOK, gin.H{"message": "model updated"}) -} - -func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { - var req struct { - ProviderConfigID string `json:"provider_config_id"` - Visibility string `json:"visibility" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var err error - if req.ProviderConfigID != "" { - err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility) - } else { - err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility) - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"}) -} - -func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { - id := c.Param("id") - if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) -} - -// ── Stats ─────────────────────────────────── - -func (h *AdminHandler) GetStats(c *gin.Context) { - ctx := c.Request.Context() - stats := gin.H{} - - var userCount, channelCount, messageCount int - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount) - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount) - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount) - - stats["users"] = userCount - stats["channels"] = channelCount - stats["messages"] = messageCount - - c.JSON(http.StatusOK, stats) -} - -// ── Helpers ───────────────────────────────── -// NOTE: ListAuditLog and ListAuditActions are in audit.go - -func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) { - userID, _ := c.Get("user_id") - uid, _ := userID.(string) - - var meta models.JSONMap - if metadata != nil { - b, _ := json.Marshal(metadata) - json.Unmarshal(b, &meta) - } - - entry := &models.AuditEntry{ - ActorID: &uid, - Action: action, - ResourceType: resourceType, - ResourceID: resourceID, - Metadata: meta, - IPAddress: c.ClientIP(), - UserAgent: c.GetHeader("User-Agent"), - } - - if err := h.stores.Audit.Log(context.Background(), entry); err != nil { - log.Printf("audit log error: %v", err) - } -} diff --git a/api.js b/api.js deleted file mode 100644 index d56393f..0000000 --- a/api.js +++ /dev/null @@ -1,636 +0,0 @@ -// ========================================== -// Chat Switchboard – API Client -// ========================================== -// Backend-only mode. Handles auth tokens and -// all HTTP calls. No offline fallback. -// -// BASE_PATH: injected via index.html - - - - Chat Switchboard - - - - - - - - - - - - - - - - - - -
-
-
-
- -
Chat Switchboard
-
-

One interface.
Every AI model.

-

Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.

-
-
Streaming responses
-
Multi-provider routing
-
🔑 Bring your own API keys
-
🏠 Self-hosted
-
✈️ Air-gap ready
-
🧠 Thinking blocks
-
-
v%%APP_VERSION%%
-
-
-
-
-
-

Welcome back

-

Sign in to continue to your workspace

-
-
- - -
-
-
-
-
- -
-
-
- - -
- -
-
-
- - - - - - - - - - - - - - - -
-
-
- - - esc -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/main.go b/main.go deleted file mode 100644 index 2631767..0000000 --- a/main.go +++ /dev/null @@ -1,508 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "git.gobha.me/xcaliber/chat-switchboard/config" - "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/events" - "git.gobha.me/xcaliber/chat-switchboard/extraction" - "git.gobha.me/xcaliber/chat-switchboard/handlers" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() -) - -func main() { - // ── Subcommand dispatch ────────────────── - // Usage: switchboard vault rekey - if len(os.Args) > 2 && os.Args[1] == "vault" { - runVaultCommand(os.Args[2]) - return - } - if len(os.Args) > 1 && os.Args[1] == "version" { - fmt.Println("switchboard", Version) - return - } - - // ── Server startup ────────────────────── - cfg := config.Load() - - // Register LLM providers - providers.Init() - - var stores store.Stores - uekCache := crypto.NewUEKCache() - var keyResolver *crypto.KeyResolver - var objStore storage.ObjectStore - - if err := database.Connect(cfg); err != nil { - log.Printf("⚠ Database unavailable: %v", err) - log.Println(" Running in unmanaged mode (no persistence)") - } else { - // Schema check: init if fresh, upgrade if behind, proceed if current - if err := database.Migrate(); err != nil { - log.Fatalf("❌ Schema migration failed: %v", err) - } - - // Vault: enforce encryption key + backfill plaintext keys - if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil { - log.Fatalf("❌ Vault check failed: %v", err) - } - if cfg.EncryptionKey != "" { - if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil { - log.Fatalf("❌ Vault backfill failed: %v", err) - } - } - - // Derive env key for key resolver (nil if not set) - var envKey []byte - if cfg.EncryptionKey != "" { - var err error - envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey) - if err != nil { - log.Fatalf("❌ Failed to derive encryption key: %v", err) - } - log.Println(" 🔐 API key encryption active") - } - keyResolver = crypto.NewKeyResolver(envKey, uekCache) - - // Initialize store layer - stores = postgres.NewStores(database.DB) - - // Bootstrap admin from env (K8s secret) — upserts on every restart - handlers.BootstrapAdmin(cfg, stores) - - // Seed additional users from env (dev/test only, skipped in production) - handlers.SeedUsers(cfg, stores) - - // Seed builtin extensions from disk (idempotent, version-aware) - handlers.SeedBuiltinExtensions(stores, "extensions/builtin") - } - defer database.Close() - - // ── File Storage ───────────────────────── - // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND - // overrides auto-detection. nil objStore = storage features disabled. - if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil { - if cfg.StorageBackend != "" { - // Explicit backend requested but failed — fatal - log.Fatalf("❌ Storage init failed: %v", err) - } - log.Printf("⚠ Storage init failed: %v", err) - } else { - objStore = s - } - handlers.SetStorageConfigured(objStore != nil) - - // ── Extraction Queue ──────────────────── - // Filesystem-based queue for document text extraction (PDF, DOCX, etc.) - // Nil if storage is disabled. - var extQueue *extraction.Queue - if objStore != nil && cfg.StoragePath != "" { - q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency) - if err != nil { - log.Printf("⚠ Extraction queue init failed: %v", err) - } else { - extQueue = q - // Recover items stuck in "processing" from previous crash - if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil { - log.Printf("⚠ Extraction recovery failed: %v", err) - } else if recovered > 0 { - log.Printf(" 📋 Recovered %d stale extraction items", recovered) - } - } - } - - // Role resolver for model role dispatch (needs stores + vault) - roleResolver := roles.NewResolver(stores, keyResolver) - - r := gin.Default() - r.Use(middleware.CORS()) - - // ── Base path group ────────────────────── - base := r.Group(cfg.BasePath) - - // ── EventBus + WebSocket Hub ───────────── - bus := events.NewBus() - hub := events.NewHub(bus) - - // Health check (k8s probes hit this directly) - base.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - "version": Version, - "database": database.IsConnected(), - "schema_version": database.SchemaVersion(), - }) - }) - - // WebSocket endpoint - base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) - - // ── Auth routes (rate limited) ────────────── - auth := handlers.NewAuthHandler(cfg, stores, uekCache) - authLimiter := middleware.NewRateLimiter(1, 5) - - api := base.Group("/api/v1") - { - // Health (routable through ingress) - api.GET("/health", func(c *gin.Context) { - info := gin.H{ - "status": "ok", - "version": Version, - "schema_version": database.SchemaVersion(), - "database": database.IsConnected(), - "providers": providers.List(), - } - if database.IsConnected() { - info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) - } - c.JSON(200, info) - }) - - authGroup := api.Group("/auth") - authGroup.Use(authLimiter.Limit()) - { - authGroup.POST("/register", auth.Register) - authGroup.POST("/login", auth.Login) - authGroup.POST("/refresh", auth.Refresh) - authGroup.POST("/logout", auth.Logout) - } - - // ── Public extension assets ──────────────── - // Script tags can't send Authorization headers, so asset serving must be public. - // Extension JS is the same for all users — no user-specific data. - extH := handlers.NewExtensionHandler(stores) - api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset) - - // ── Protected routes ──────────────────── - protected := api.Group("") - protected.Use(middleware.Auth(cfg)) - { - // Channels - channels := handlers.NewChannelHandler() - protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", channels.CreateChannel) - protected.GET("/channels/:id", channels.GetChannel) - protected.PUT("/channels/:id", channels.UpdateChannel) - protected.DELETE("/channels/:id", channels.DeleteChannel) - - // Messages - msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) - protected.GET("/channels/:id/messages", msgs.ListMessages) - protected.POST("/channels/:id/messages", msgs.CreateMessage) - - // Message tree (forking) - protected.GET("/channels/:id/path", msgs.GetActivePath) - protected.PUT("/channels/:id/cursor", msgs.UpdateCursor) - protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) - protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) - protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) - - // Chat Completions - comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore) - protected.POST("/chat/completions", comp.Complete) - - // Summarize & Continue - summarize := handlers.NewSummarizeHandler(stores, roleResolver) - protected.POST("/channels/:id/summarize", summarize.Summarize) - - // Provider Configs (user-facing — replaces /api-configs) - provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) - protected.GET("/api-configs", provCfg.ListConfigs) // backward compat - protected.POST("/api-configs", provCfg.CreateConfig) - protected.GET("/api-configs/:id", provCfg.GetConfig) - protected.PUT("/api-configs/:id", provCfg.UpdateConfig) - protected.DELETE("/api-configs/:id", provCfg.DeleteConfig) - protected.GET("/api-configs/:id/models", provCfg.ListModels) - protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) - - // Models (unified resolver — replaces scattered endpoints) - modelH := handlers.NewModelHandler(stores) - protected.GET("/models/enabled", modelH.ListEnabledModels) - protected.GET("/models", modelH.ListEnabledModels) // alias - - // Model Preferences - modelPrefs := handlers.NewModelPrefsHandler(stores) - protected.GET("/models/preferences", modelPrefs.GetPreferences) - protected.PUT("/models/preferences", modelPrefs.SetPreference) - protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) - - // User Settings & Profile - settings := handlers.NewSettingsHandler(uekCache) - protected.GET("/profile", settings.GetProfile) - protected.PUT("/profile", settings.UpdateProfile) - protected.POST("/profile/password", settings.ChangePassword) - protected.POST("/profile/avatar", settings.UploadAvatar) - protected.DELETE("/profile/avatar", settings.DeleteAvatar) - protected.GET("/settings", settings.GetSettings) - protected.PUT("/settings", settings.UpdateSettings) - - // Usage (personal) - usage := handlers.NewUsageHandler(stores) - protected.GET("/usage", usage.PersonalUsage) - - // Personas (replaces /presets) - personas := handlers.NewPersonaHandler(stores) - protected.GET("/presets", personas.ListUserPersonas) // backward compat - protected.POST("/presets", personas.CreateUserPersona) - protected.PUT("/presets/:id", personas.UpdateUserPersona) - protected.DELETE("/presets/:id", personas.DeleteUserPersona) - protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) - protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) - - // Notes - notes := handlers.NewNoteHandler() - protected.GET("/notes", notes.List) - protected.POST("/notes", notes.Create) - protected.GET("/notes/search", notes.Search) - protected.GET("/notes/folders", notes.ListFolders) - protected.POST("/notes/bulk-delete", notes.BulkDelete) - protected.GET("/notes/:id", notes.Get) - protected.PUT("/notes/:id", notes.Update) - protected.DELETE("/notes/:id", notes.Delete) - - // Attachments (file upload/download) - attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) - protected.POST("/channels/:id/attachments", attachH.Upload) - protected.GET("/channels/:id/attachments", attachH.ListByChannel) - protected.GET("/attachments/:id", attachH.GetMetadata) - protected.GET("/attachments/:id/download", attachH.Download) - protected.DELETE("/attachments/:id", attachH.DeleteAttachment) - - // Hook: clean up storage files when channels are deleted - handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage) - - // Teams (user: my teams) - teams := handlers.NewTeamHandler(keyResolver) - protected.GET("/teams/mine", teams.MyTeams) - - // Team admin self-service - teamScoped := protected.Group("/teams/:teamId") - teamScoped.Use(middleware.RequireTeamAdmin()) - { - teamScoped.GET("/members", teams.ListMembers) - teamScoped.POST("/members", teams.AddMember) - teamScoped.PUT("/members/:memberId", teams.UpdateMember) - teamScoped.DELETE("/members/:memberId", teams.RemoveMember) - teamScoped.GET("/models", teams.ListAvailableModels) - - // Team providers - teamScoped.GET("/providers", teams.ListTeamProviders) - teamScoped.POST("/providers", teams.CreateTeamProvider) - teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) - teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) - teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) - - // Team audit log (team admins only — RequireTeamAdmin on group) - teamScoped.GET("/audit", teams.ListTeamAuditLog) - teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) - - // Team usage (team admins only — usage against team-owned providers) - teamUsage := handlers.NewUsageHandler(stores) - teamScoped.GET("/usage", teamUsage.TeamUsage) - - // Team personas - teamPersonas := handlers.NewPersonaHandler(stores) - teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) - teamScoped.POST("/presets", teamPersonas.CreateTeamPersona) - teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona) - - // Team role overrides - teamRoles := handlers.NewRolesHandler(stores, roleResolver) - teamScoped.GET("/roles", teamRoles.ListTeamRoles) - teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) - teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) - } - - // Public global settings (non-admin users can read safe subset) - adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) - protected.GET("/settings/public", adm.PublicSettings) - - // Extensions (user-facing) - protected.GET("/extensions", extH.ListUserExtensions) - protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings) - protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest) - protected.GET("/extensions/tools", extH.ListBrowserToolSchemas) - } - - // ── Admin routes ──────────────────────── - admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg)) - admin.Use(middleware.RequireAdmin()) - { - adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) - - // User management - admin.GET("/users", adm.ListUsers) - admin.POST("/users", adm.CreateUser) - admin.PUT("/users/:id/role", adm.UpdateUserRole) - admin.PUT("/users/:id/active", adm.ToggleUserActive) - admin.POST("/users/:id/reset-password", adm.ResetPassword) - admin.DELETE("/users/:id", adm.DeleteUser) - - // Global settings - admin.GET("/settings", adm.ListGlobalSettings) - admin.GET("/settings/:key", adm.GetGlobalSetting) - admin.PUT("/settings/:key", adm.UpdateGlobalSetting) - - // Stats - admin.GET("/stats", adm.GetStats) - - // Global Provider Configs - admin.GET("/configs", adm.ListGlobalConfigs) - admin.POST("/configs", adm.CreateGlobalConfig) - admin.PUT("/configs/:id", adm.UpdateGlobalConfig) - admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) - - // Model Catalog - admin.GET("/models", adm.ListModelConfigs) - admin.POST("/models/fetch", adm.FetchModels) - admin.PUT("/models/bulk", adm.BulkUpdateModels) - admin.PUT("/models/:id", adm.UpdateModelConfig) - admin.DELETE("/models/:id", adm.DeleteModelConfig) - - // Personas (admin global) - personaAdm := handlers.NewPersonaHandler(stores) - admin.GET("/presets", personaAdm.ListAdminPersonas) - admin.POST("/presets", personaAdm.CreateAdminPersona) - admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona) - admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona) - admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) - admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) - - // Teams (admin) - teamAdm := handlers.NewTeamHandler(keyResolver) - admin.GET("/teams", teamAdm.ListTeams) - admin.POST("/teams", teamAdm.CreateTeam) - admin.GET("/teams/:id", teamAdm.GetTeam) - admin.PUT("/teams/:id", teamAdm.UpdateTeam) - admin.DELETE("/teams/:id", teamAdm.DeleteTeam) - admin.GET("/teams/:id/members", teamAdm.ListMembers) - admin.POST("/teams/:id/members", teamAdm.AddMember) - admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember) - admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) - - // Audit log - admin.GET("/audit", adm.ListAuditLog) - admin.GET("/audit/actions", adm.ListAuditActions) - - // Model Roles - rolesH := handlers.NewRolesHandler(stores, roleResolver) - admin.GET("/roles", rolesH.ListRoles) - admin.GET("/roles/:role", rolesH.GetRole) - admin.PUT("/roles/:role", rolesH.UpdateRole) - admin.POST("/roles/:role/test", rolesH.TestRole) - - // Usage & Pricing - usageH := handlers.NewUsageHandler(stores) - admin.GET("/usage", usageH.AdminUsage) - admin.GET("/usage/users/:id", usageH.AdminUserUsage) - admin.GET("/usage/teams/:id", usageH.AdminTeamUsage) - admin.GET("/pricing", usageH.ListPricing) - admin.PUT("/pricing", usageH.UpsertPricing) - admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) - - // Storage status - storageH := handlers.NewStorageHandler(objStore) - admin.GET("/storage/status", storageH.Status) - - // Vault - admin.GET("/vault/status", adm.VaultStatus) - - // Storage management (orphan cleanup) - attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue) - admin.GET("/storage/orphans", attachAdm.OrphanCount) - admin.POST("/storage/cleanup", attachAdm.CleanupOrphans) - admin.GET("/storage/extraction", attachAdm.ExtractionStatus) - - // Extensions (admin) - extAdm := handlers.NewExtensionHandler(stores) - admin.GET("/extensions", extAdm.AdminListExtensions) - admin.POST("/extensions", extAdm.AdminInstallExtension) - admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension) - admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension) - } - } - - bp := cfg.BasePath - if bp == "" { - bp = "/" - } - log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) - log.Printf(" Base path: %s", bp) - log.Printf(" Schema: %s", database.SchemaVersion()) - log.Printf(" Providers: %v", providers.List()) - if objStore != nil { - log.Printf(" Storage: %s", objStore.Backend()) - if extQueue != nil { - log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency) - } else { - log.Printf(" Extraction: disabled") - } - } else { - log.Printf(" Storage: disabled") - } - log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) - if err := r.Run(":" + cfg.Port); err != nil { - log.Fatalf("Failed to start server: %v", err) - } -} - -// ── Vault CLI Commands ────────────────────── - -func runVaultCommand(subcmd string) { - cfg := config.Load() - - switch strings.ToLower(subcmd) { - case "rekey": - if cfg.DatabaseURL == "" { - log.Fatal("DATABASE_URL is required for vault operations") - } - if err := database.Connect(cfg); err != nil { - log.Fatalf("❌ Database connection failed: %v", err) - } - defer database.Close() - - oldKey := os.Getenv("ENCRYPTION_KEY") - newKey := os.Getenv("NEW_ENCRYPTION_KEY") - - if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil { - log.Fatalf("❌ Vault rekey failed: %v", err) - } - - case "status": - if cfg.DatabaseURL == "" { - log.Fatal("DATABASE_URL is required for vault operations") - } - if err := database.Connect(cfg); err != nil { - log.Fatalf("❌ Database connection failed: %v", err) - } - defer database.Close() - - status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey) - if err != nil { - log.Fatalf("❌ Failed to get vault status: %v", err) - } - fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet) - fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys) - fmt.Printf("Vault users (active): %d\n", status.VaultUsers) - - default: - fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd) - fmt.Fprintf(os.Stderr, "Usage: switchboard vault \n") - os.Exit(1) - } -} diff --git a/rekey.go b/rekey.go deleted file mode 100644 index 9e75aa8..0000000 --- a/rekey.go +++ /dev/null @@ -1,115 +0,0 @@ -package crypto - -import ( - "database/sql" - "fmt" - "log" -) - -// Rekey re-encrypts all global/team API keys from oldKey to newKey. -// Personal BYOK keys are unaffected (they're keyed to the user's UEK, -// not the environment-derived key). -// -// Runs in a single transaction — all-or-nothing. On failure, no keys -// are modified. -// -// Usage: -// -// switchboard vault rekey -// ENCRYPTION_KEY = current/old key -// NEW_ENCRYPTION_KEY = new key to re-encrypt with -func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error { - if oldEnvKey == "" { - return fmt.Errorf("ENCRYPTION_KEY (current key) is required") - } - if newEnvKey == "" { - return fmt.Errorf("NEW_ENCRYPTION_KEY is required") - } - if oldEnvKey == newEnvKey { - return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do") - } - - oldKey, err := DeriveKeyFromEnv(oldEnvKey) - if err != nil { - return fmt.Errorf("failed to derive old key: %w", err) - } - - newKey, err := DeriveKeyFromEnv(newEnvKey) - if err != nil { - return fmt.Errorf("failed to derive new key: %w", err) - } - - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() // no-op after commit - - // Find all global/team keys that are encrypted - rows, err := tx.Query(` - SELECT id, api_key_enc, key_nonce - FROM provider_configs - WHERE key_scope IN ('global', 'team') - AND api_key_enc IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query encrypted keys: %w", err) - } - - type rekeyRow struct { - id string - ciphertext []byte - nonce []byte - } - - var toRekey []rekeyRow - for rows.Next() { - var r rekeyRow - if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil { - rows.Close() - return fmt.Errorf("failed to scan row: %w", err) - } - toRekey = append(toRekey, r) - } - rows.Close() - if err := rows.Err(); err != nil { - return fmt.Errorf("row iteration error: %w", err) - } - - if len(toRekey) == 0 { - log.Println("No global/team encrypted keys found — nothing to rekey.") - return nil - } - - log.Printf("Rekeying %d provider config(s)...", len(toRekey)) - - // Decrypt with old key, re-encrypt with new key, update in place - for _, r := range toRekey { - plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey) - if err != nil { - return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err) - } - - newCiphertext, newNonce, err := Encrypt(plaintext, newKey) - if err != nil { - return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err) - } - - _, err = tx.Exec(` - UPDATE provider_configs - SET api_key_enc = $1, key_nonce = $2 - WHERE id = $3 - `, newCiphertext, newNonce, r.id) - if err != nil { - return fmt.Errorf("failed to update config %s: %w", r.id, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit rekey transaction: %w", err) - } - - log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey)) - log.Println("Update ENCRYPTION_KEY to the new value and restart the server.") - return nil -} diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 1f948ed..547296e 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -176,6 +176,8 @@ func TruncateAll(t *testing.T) { "teams", "refresh_tokens", "users", + "platform_policies", + "global_config", } for _, table := range tables { DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) diff --git a/src/css/styles.css b/src/css/styles.css index 8a34859..5185826 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -6,6 +6,7 @@ ========================================== */ :root { + /* ── Core palette ────────────────────────── */ --bg: #0e0e10; --bg-surface: #18181b; --bg-raised: #222227; @@ -23,6 +24,27 @@ --warning: #eab308; --purple: #a78bfa; --purple-dim: rgba(167,139,250,0.10); + + /* ── Contrast text for solid-color backgrounds ── */ + --text-on-color: #fff; + --text-on-warning: #000; + + /* ── Light variants (badge/pill text on dim backgrounds) ── */ + --accent-light: #93c5fd; + --danger-light: #f87171; + --success-light: #4ade80; + --warning-light: #fbbf24; + + /* ── Dim variants (badge/pill backgrounds) ── */ + --danger-dim: rgba(239,68,68,0.2); + --success-dim: rgba(34,197,94,0.2); + --warning-dim: rgba(234,179,8,0.2); + + /* ── Surfaces ────────────────────────────── */ + --overlay: rgba(0,0,0,0.6); + --glass: rgba(255,255,255,0.03); + + /* ── Layout ──────────────────────────────── */ --radius: 8px; --radius-lg: 12px; --sidebar-w: 260px; @@ -406,7 +428,7 @@ a:hover { text-decoration: underline; } .messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; } .message { padding: 1.25rem 0; } -.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); } +.message:not(:last-child) { border-bottom: 1px solid var(--glass); } .msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; } @@ -506,7 +528,7 @@ a:hover { text-decoration: underline; } .msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); } .msg-edit-submit { background: var(--accent); - color: #fff; + color: var(--text-on-color); border-color: var(--accent); } .msg-edit-submit:hover { background: var(--accent-hover); } @@ -701,7 +723,7 @@ a:hover { text-decoration: underline; } /* Thinking blocks */ .thinking-block { - border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius); + border: 1px solid var(--accent-dim); border-radius: var(--radius); margin: 0.5rem 0; background: rgba(108,159,255,0.03); } .thinking-block summary { @@ -831,15 +853,15 @@ a:hover { text-decoration: underline; } .context-warning-icon { flex-shrink: 0; } .context-warning-text { flex: 1; } .context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; } -.context-warning-dismiss:hover { color: var(--text-1); } +.context-warning-dismiss:hover { color: var(--text); } .context-warning-action { flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border); - background: var(--bg-surface); color: var(--text-1); cursor: pointer; + background: var(--bg-surface); color: var(--text); cursor: pointer; font-size: 0.75rem; white-space: nowrap; transition: all var(--transition); } -.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.context-warning-action:hover { background: var(--accent); color: var(--text-on-color); border-color: var(--accent); } .context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; } -.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); } +.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text); border-color: var(--border); } /* Summary message node */ .message-summary { border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); @@ -855,7 +877,7 @@ a:hover { text-decoration: underline; } background: none; border: none; color: var(--accent); cursor: pointer; font-size: 0.75rem; padding: 0; text-decoration: underline; } -.message-summary .summary-toggle:hover { color: var(--text-1); } +.message-summary .summary-toggle:hover { color: var(--text); } .msg-dimmed { opacity: 0.5; } .message-summary-divider { text-align: center; padding: 8px 0; margin: 4px 0; @@ -918,7 +940,7 @@ a:hover { text-decoration: underline; } font-size: 12px; max-width: 220px; position: relative; } .att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); } -.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); } +.att-chip.att-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); } .att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } .att-thumb { @@ -1005,7 +1027,7 @@ a:hover { text-decoration: underline; } } .lightbox-close { position: absolute; top: 16px; right: 20px; - background: rgba(255,255,255,0.15); border: none; color: #fff; + background: rgba(255,255,255,0.15); border: none; color: var(--text-on-color); font-size: 22px; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; @@ -1032,7 +1054,7 @@ a:hover { text-decoration: underline; } button { font-family: var(--font); cursor: pointer; } .btn-primary { - background: var(--accent); color: #fff; border: none; + background: var(--accent); color: var(--text-on-color); border: none; padding: 8px 16px; border-radius: var(--radius); font-weight: 500; font-size: 13px; transition: background var(--transition); @@ -1046,8 +1068,8 @@ button { font-family: var(--font); cursor: pointer; } font-size: 12px; transition: all var(--transition); } .btn-small:hover { background: var(--bg-hover); color: var(--text); } -.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); } +.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-color); } +.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; border-radius: var(--radius); } /* ── Auth Splash ─────────────────────────── */ @@ -1147,7 +1169,7 @@ button { font-family: var(--font); cursor: pointer; } .auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } .splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; } .splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; } -.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; } +.splash-error-hint { color: var(--text-3); font-size: 0.72rem; } .splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; } .auth-actions { margin-top: 1.5rem; } .auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; } @@ -1200,14 +1222,26 @@ button { font-family: var(--font); cursor: pointer; } /* ── Forms ────────────────────────────────── */ -.form-group { margin-bottom: 0.75rem; } -.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } -.form-group input, .form-group select, .form-group textarea { - width: 100%; background: var(--bg-raised); border: 1px solid var(--border); +/* Base form element styling — ensures selects/inputs match the dark theme + even when not wrapped in .form-group (e.g. role config, inline toolbars) */ +select, input[type="text"], input[type="email"], input[type="password"], +input[type="number"], input[type="search"], textarea { + background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 8px 12px; border-radius: var(--radius); font-family: var(--font); font-size: 13px; transition: border-color var(--transition); } +select:focus, input[type="text"]:focus, input[type="email"]:focus, +input[type="password"]:focus, textarea:focus { + outline: none; border-color: var(--accent); +} +select option { background: var(--bg-surface); color: var(--text); } + +.form-group { margin-bottom: 0.75rem; } +.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; +} .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); } @@ -1233,7 +1267,7 @@ button { font-family: var(--font); cursor: pointer; } /* ── Modal ────────────────────────────────── */ .modal-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.6); + position: fixed; inset: 0; background: var(--overlay); display: none; align-items: center; justify-content: center; z-index: 1000; backdrop-filter: blur(2px); padding: 2rem; /* keeps modal away from viewport edges at any zoom */ @@ -1324,7 +1358,7 @@ button { font-family: var(--font); cursor: pointer; } .settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .settings-notice { - background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2); + background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim); border-radius: var(--radius); padding: 10px 14px; font-size: 13px; color: var(--warning); display: flex; align-items: center; gap: 8px; margin-top: 1rem; @@ -1340,17 +1374,66 @@ button { font-family: var(--font); cursor: pointer; } .provider-actions { display: flex; align-items: center; gap: 8px; } .badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; } -/* Admin tabs */ -.admin-tabs { } +/* Admin panel (fullscreen) */ +.admin-panel { + position: fixed; z-index: 1000; + top: var(--banner-top-height); right: 0; + bottom: var(--banner-bottom-height); left: 0; + background: var(--bg); display: flex; flex-direction: column; +} +.admin-topbar { + display: flex; align-items: center; padding: 0 1rem; height: 48px; + border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; +} +.admin-back { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 14px; padding: 4px 10px; border-radius: 4px; font-family: var(--font); +} +.admin-back:hover { color: var(--accent); background: var(--bg-raised); } +.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; } +.admin-user { font-size: 12px; color: var(--text-3); } + +.admin-categories { + display: flex; gap: 0; border-bottom: 1px solid var(--border); + background: var(--bg-surface); padding: 0 1rem; flex-shrink: 0; +} +.admin-cat { + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--text-3); padding: 10px 20px; cursor: pointer; + font-size: 14px; font-weight: 500; font-family: var(--font); + transition: color 0.15s, border-color 0.15s; outline: none; +} +.admin-cat:hover { color: var(--text-2); } +.admin-cat.active { color: var(--accent); border-bottom-color: var(--accent); } + +.admin-body { display: flex; flex: 1; overflow: hidden; } + +.admin-sidebar { + width: 160px; flex-shrink: 0; border-right: 1px solid var(--border); + background: var(--bg-surface); padding: 0.75rem 0; overflow-y: auto; +} +.admin-sidebar button { + display: block; width: 100%; text-align: left; background: none; border: none; + padding: 8px 20px; color: var(--text-3); cursor: pointer; + font-size: 13px; font-family: var(--font); border-left: 3px solid transparent; + transition: color 0.1s, background 0.1s; +} +.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); } +.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); } + +.admin-main { flex: 1; overflow-y: auto; padding: 1.5rem 2rem; } +.admin-section-content { /* replaces .admin-tab-content */ } + +/* Admin shared */ +/* admin-tab: still used by team admin modal */ .admin-tab { padding: 10px 14px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; transition: color var(--transition); - outline: none; + outline: none; font-family: var(--font); } .admin-tab:hover { color: var(--text-2); } -.admin-tab:focus { color: var(--text-3); } -.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); } +.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; } .admin-hint { font-size: 12px; color: var(--text-3); } @@ -1387,7 +1470,7 @@ button { font-family: var(--font); cursor: pointer; } .admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; } .admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); } .admin-model-toggle.enabled { border-color: var(--success); color: var(--success); } -.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; } +.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); } .model-list-item.model-hidden { opacity: 0.5; } .model-list-item.model-hidden .model-name { text-decoration: line-through; } @@ -1411,7 +1494,7 @@ button { font-family: var(--font); cursor: pointer; } .admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } .admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } .admin-preset-row .btn-delete:hover { color: var(--danger); } -.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-bg); color: var(--accent); } +.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); } /* Stats cards */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } @@ -1464,15 +1547,15 @@ button { font-family: var(--font); cursor: pointer; } .model-list-item .model-provider { font-size: 12px; color: var(--text-3); } .model-list-item .model-caps-inline { display: flex; gap: 3px; } -.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-admin { background: var(--accent); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-team { background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } -.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: rgba(255,255,255,0.03); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } +.badge-inactive { background: var(--danger); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-pending { background: var(--warning); color: var(--text-on-warning); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-team { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } +.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } .admin-approve-form .checkbox-label { display: block; margin: 2px 0; } -.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } -.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; } +.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } +.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; } .team-card-info { display: flex; align-items: center; gap: 8px; } .team-admin-back { cursor: pointer; margin-right: 4px; opacity: 0.5; @@ -1480,10 +1563,10 @@ button { font-family: var(--font); cursor: pointer; } } .team-admin-back:hover { opacity: 1; } .team-tab-content { padding: 0; } -.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-private { background: var(--purple); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .admin-user-row.user-inactive { opacity: 0.7; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } .error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } @@ -1687,8 +1770,8 @@ button { font-family: var(--font); cursor: pointer; } .notes-editor input, .notes-editor textarea { width: 100%; background: var(--bg-surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); - padding: 6px 10px; font-family: var(--font); outline: none; - transition: border-color var(--transition); + padding: 6px 10px; font-family: var(--font); font-size: var(--msg-font, 14px); + outline: none; transition: border-color var(--transition); } .notes-editor input:focus, .notes-editor textarea:focus { border-color: var(--accent); @@ -1730,7 +1813,7 @@ button { font-family: var(--font); cursor: pointer; } /* ── Command Palette ─────────────────────── */ .cmd-palette-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.5); + position: fixed; inset: 0; background: var(--overlay); display: none; align-items: flex-start; justify-content: center; z-index: 2000; backdrop-filter: blur(4px); padding-top: min(20vh, 160px); @@ -1817,7 +1900,7 @@ button { font-family: var(--font); cursor: pointer; } .confirm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; - background: rgba(0,0,0,0.55); + background: var(--overlay); animation: fade-in 0.12s ease; } .confirm-dialog { @@ -1876,7 +1959,7 @@ button { font-family: var(--font); cursor: pointer; } .mobile-menu-btn:hover { color: var(--text); } .sidebar-overlay { display: none; position: fixed; inset: 0; z-index: 99; - background: rgba(0,0,0,0.5); + background: var(--overlay); } @media (max-width: 768px) { @@ -1891,4 +1974,9 @@ button { font-family: var(--font); cursor: pointer; } .modal { width: 100%; } .tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */ .modal-tabs { padding: 0 12px; } + .admin-sidebar { width: 48px; padding: 0.5rem 0; } + .admin-sidebar button { padding: 8px 0; text-align: center; font-size: 11px; border-left: none; border-bottom: 2px solid transparent; } + .admin-sidebar button.active { border-bottom-color: var(--accent); border-left: none; } + .admin-main { padding: 1rem; } + .admin-cat { padding: 10px 12px; font-size: 13px; } } diff --git a/src/index.html b/src/index.html index 0393f82..e23c471 100644 --- a/src/index.html +++ b/src/index.html @@ -425,7 +425,7 @@ -

Toggle visibility in your model selector. Hidden models are still accessible through presets.

+

Toggle visibility in your model selector. Hidden models are still accessible through personas.

Loading models...
@@ -433,7 +433,7 @@ -