From 2fc4f6980c8f2c214bd74f66582f98ef44adbcd8 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Mon, 16 Feb 2026 20:30:36 +0000 Subject: [PATCH] Feature user and admin panels (#30) --- VERSION | 2 +- migrations/003_global_settings.sql | 15 ++ server/handlers/admin.go | 386 +++++++++++++++++++++++++++++ server/handlers/admin_test.go | 27 ++ server/handlers/auth.go | 40 ++- server/handlers/settings.go | 208 ++++++++++++++++ server/main.go | 49 +++- server/main_test.go | 54 +++- server/middleware/admin.go | 22 ++ src/css/styles.css | 349 ++++++++++++++++++++++++++ src/index.html | 154 ++++++++++-- src/js/admin.js | 273 ++++++++++++++++++++ src/js/app.js | 165 ++++++------ src/js/backend.js | 93 +++++++ src/js/ui.js | 79 ++++++ 15 files changed, 1805 insertions(+), 111 deletions(-) create mode 100644 migrations/003_global_settings.sql create mode 100644 server/handlers/admin.go create mode 100644 server/handlers/admin_test.go create mode 100644 server/handlers/settings.go create mode 100644 server/middleware/admin.go create mode 100644 src/js/admin.js diff --git a/VERSION b/VERSION index 341cf11..9325c3c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 \ No newline at end of file +0.3.0 \ No newline at end of file diff --git a/migrations/003_global_settings.sql b/migrations/003_global_settings.sql new file mode 100644 index 0000000..5c10899 --- /dev/null +++ b/migrations/003_global_settings.sql @@ -0,0 +1,15 @@ +-- Migration 003: Global Settings +-- Stores application-wide configuration managed by admins. + +CREATE TABLE IF NOT EXISTS global_settings ( + key VARCHAR(100) PRIMARY KEY, + value JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_by UUID REFERENCES users(id) +); + +-- Seed defaults +INSERT INTO global_settings (key, value) VALUES + ('registration', '{"enabled": true}'::jsonb), + ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/server/handlers/admin.go b/server/handlers/admin.go new file mode 100644 index 0000000..e7e0821 --- /dev/null +++ b/server/handlers/admin.go @@ -0,0 +1,386 @@ +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" +) + +// ── 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"}) +} + +// ── 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_chats": "SELECT COUNT(*) FROM chats", + "total_messages": "SELECT COUNT(*) FROM chat_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) +} diff --git a/server/handlers/admin_test.go b/server/handlers/admin_test.go new file mode 100644 index 0000000..64ffe4c --- /dev/null +++ b/server/handlers/admin_test.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "testing" +) + +func TestNewSettingsHandler(t *testing.T) { + h := NewSettingsHandler() + if h == nil { + t.Fatal("NewSettingsHandler returned nil") + } +} + +func TestNewAdminHandler(t *testing.T) { + h := NewAdminHandler() + if h == nil { + t.Fatal("NewAdminHandler returned nil") + } +} + +func TestIsRegistrationEnabledDefaultsTrue(t *testing.T) { + // Without a database connection, should default to true + enabled := IsRegistrationEnabled() + if !enabled { + t.Error("Expected registration enabled by default when no DB") + } +} diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 4f0d902..4229cd2 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -86,6 +86,19 @@ func (h *AuthHandler) Register(c *gin.Context) { req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Username = strings.TrimSpace(req.Username) + // Check if this is the first user (will become admin) + var userCount int + _ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) + isFirstUser := userCount == 0 + + // If not first user, check if registration is enabled + if !isFirstUser { + if !IsRegistrationEnabled() { + c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) + return + } + } + // Hash password hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) if err != nil { @@ -93,13 +106,19 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // First user gets admin role + role := "user" + if isFirstUser { + role = "admin" + } + // Insert user var user userResponse err = database.DB.QueryRow(` INSERT INTO users (username, email, password_hash, role) - VALUES ($1, $2, $3, 'user') + VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, role - `, req.Username, req.Email, string(hash)).Scan( + `, req.Username, req.Email, string(hash), role).Scan( &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, ) if err != nil { @@ -125,6 +144,23 @@ func (h *AuthHandler) Register(c *gin.Context) { c.JSON(http.StatusCreated, resp) } +// IsRegistrationEnabled checks the global_settings table. +// Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled. +func IsRegistrationEnabled() bool { + if database.DB == nil { + return true + } + var enabled bool + err := database.DB.QueryRow(` + SELECT COALESCE((value->>'enabled')::boolean, true) + FROM global_settings WHERE key = 'registration' + `).Scan(&enabled) + if err != nil { + return true // Default to open if setting missing + } + return enabled +} + // ── Login ─────────────────────────────────── func (h *AuthHandler) Login(c *gin.Context) { diff --git a/server/handlers/settings.go b/server/handlers/settings.go new file mode 100644 index 0000000..5f01b34 --- /dev/null +++ b/server/handlers/settings.go @@ -0,0 +1,208 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Request Types ─────────────────────────── + +type updateProfileRequest struct { + DisplayName *string `json:"display_name,omitempty"` + Email *string `json:"email,omitempty"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8,max=128"` +} + +type profileResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName *string `json:"display_name"` + Role string `json:"role"` + Settings map[string]interface{} `json:"settings"` + CreatedAt string `json:"created_at"` +} + +// SettingsHandler manages user profile and preferences. +type SettingsHandler struct{} + +// NewSettingsHandler creates a new handler. +func NewSettingsHandler() *SettingsHandler { + return &SettingsHandler{} +} + +// ── Get Profile ───────────────────────────── + +func (h *SettingsHandler) GetProfile(c *gin.Context) { + userID := getUserID(c) + + var p profileResponse + var settingsRaw string + err := database.DB.QueryRow(` + SELECT id, username, email, display_name, role, settings::text, created_at + FROM users WHERE id = $1 + `, userID).Scan( + &p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role, + &settingsRaw, &p.CreatedAt, + ) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load profile"}) + return + } + + p.Settings = make(map[string]interface{}) + _ = json.Unmarshal([]byte(settingsRaw), &p.Settings) + + c.JSON(http.StatusOK, p) +} + +// ── Update Profile ────────────────────────── + +func (h *SettingsHandler) UpdateProfile(c *gin.Context) { + userID := getUserID(c) + + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.DisplayName != nil { + _, err := database.DB.Exec( + `UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`, + *req.DisplayName, userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update display name"}) + return + } + } + + if req.Email != nil { + email := strings.ToLower(strings.TrimSpace(*req.Email)) + _, err := database.DB.Exec( + `UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`, + email, userID, + ) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + c.JSON(http.StatusConflict, gin.H{"error": "email already taken"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update email"}) + return + } + } + + h.GetProfile(c) +} + +// ── Change Password ───────────────────────── + +func (h *SettingsHandler) ChangePassword(c *gin.Context) { + userID := getUserID(c) + + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify current password + var hash string + err := database.DB.QueryRow( + `SELECT password_hash FROM users WHERE id = $1`, userID, + ).Scan(&hash) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.CurrentPassword)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"}) + return + } + + // Hash new password + newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + _, err = database.DB.Exec( + `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, + string(newHash), userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "password updated"}) +} + +// ── Get User Settings (JSONB) ─────────────── + +func (h *SettingsHandler) GetSettings(c *gin.Context) { + userID := getUserID(c) + + var settingsRaw string + err := database.DB.QueryRow( + `SELECT settings::text FROM users WHERE id = $1`, userID, + ).Scan(&settingsRaw) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) + return + } + + settings := make(map[string]interface{}) + _ = json.Unmarshal([]byte(settingsRaw), &settings) + + c.JSON(http.StatusOK, settings) +} + +// ── Update User Settings (JSONB merge) ────── + +func (h *SettingsHandler) UpdateSettings(c *gin.Context) { + userID := getUserID(c) + + var incoming map[string]interface{} + if err := c.ShouldBindJSON(&incoming); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch, err := json.Marshal(incoming) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) + return + } + + // JSONB merge — existing keys preserved, incoming keys overwrite + _, err = database.DB.Exec(` + UPDATE users SET settings = settings || $1::jsonb, updated_at = NOW() + WHERE id = $2 + `, string(patch), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) + return + } + + h.GetSettings(c) +} diff --git a/server/main.go b/server/main.go index 96939d2..cc64ea8 100644 --- a/server/main.go +++ b/server/main.go @@ -44,12 +44,17 @@ func main() { { // Health (routable through ingress) api.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ + info := gin.H{ "status": "ok", "version": Version, "database": database.IsConnected(), "providers": providers.List(), - }) + } + // Include registration status for frontend + if database.IsConnected() { + info["registration_enabled"] = isRegistrationOpen() + } + c.JSON(200, info) }) authGroup := api.Group("/auth") @@ -95,9 +100,37 @@ func main() { protected.GET("/api-configs/:id/models", apiCfg.ListModels) protected.GET("/models", apiCfg.ListAllModels) - // Settings — future - protected.GET("/settings", stubHandler("settings")) - protected.PUT("/settings", stubHandler("settings")) + // User Settings & Profile + settings := handlers.NewSettingsHandler() + protected.GET("/profile", settings.GetProfile) + protected.PUT("/profile", settings.UpdateProfile) + protected.POST("/profile/password", settings.ChangePassword) + protected.GET("/settings", settings.GetSettings) + protected.PUT("/settings", settings.UpdateSettings) + } + + // ── Admin routes ──────────────────────── + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg)) + admin.Use(middleware.RequireAdmin()) + { + adm := handlers.NewAdminHandler() + + // 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) } } @@ -108,8 +141,6 @@ func main() { } } -func stubHandler(feature string) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(501, gin.H{"error": feature + " not implemented"}) - } +func isRegistrationOpen() bool { + return handlers.IsRegistrationEnabled() } diff --git a/server/main_test.go b/server/main_test.go index eaf8c08..794e575 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -57,6 +57,8 @@ func TestAllRoutesRegistered(t *testing.T) { msgs := handlers.NewMessageHandler() comp := handlers.NewCompletionHandler() apiCfg := handlers.NewAPIConfigHandler() + settings := handlers.NewSettingsHandler() + adm := handlers.NewAdminHandler() r := gin.New() api := r.Group("/api/v1") @@ -94,6 +96,28 @@ func TestAllRoutesRegistered(t *testing.T) { protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig) protected.GET("/api-configs/:id/models", apiCfg.ListModels) protected.GET("/models", apiCfg.ListAllModels) + + // User Settings & Profile + protected.GET("/profile", settings.GetProfile) + protected.PUT("/profile", settings.UpdateProfile) + protected.POST("/profile/password", settings.ChangePassword) + protected.GET("/settings", settings.GetSettings) + protected.PUT("/settings", settings.UpdateSettings) + } + + // Admin routes + admin := api.Group("/admin") + { + 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) + admin.GET("/settings", adm.ListGlobalSettings) + admin.GET("/settings/:key", adm.GetGlobalSetting) + admin.PUT("/settings/:key", adm.UpdateGlobalSetting) + admin.GET("/stats", adm.GetStats) } routes := r.Routes() @@ -129,6 +153,23 @@ func TestAllRoutesRegistered(t *testing.T) { "GET /api/v1/api-configs/:id/models", // Models "GET /api/v1/models", + // Profile & Settings + "GET /api/v1/profile", + "PUT /api/v1/profile", + "POST /api/v1/profile/password", + "GET /api/v1/settings", + "PUT /api/v1/settings", + // Admin + "GET /api/v1/admin/users", + "POST /api/v1/admin/users", + "PUT /api/v1/admin/users/:id/role", + "PUT /api/v1/admin/users/:id/active", + "POST /api/v1/admin/users/:id/reset-password", + "DELETE /api/v1/admin/users/:id", + "GET /api/v1/admin/settings", + "GET /api/v1/admin/settings/:key", + "PUT /api/v1/admin/settings/:key", + "GET /api/v1/admin/stats", } for _, e := range expected { @@ -192,19 +233,6 @@ func TestAnthropicStaticModels(t *testing.T) { } } -func TestStubHandler(t *testing.T) { - r := gin.New() - r.GET("/test", stubHandler("settings")) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - - if w.Code != 501 { - t.Errorf("Expected 501, got %d", w.Code) - } -} - func TestAuthMiddlewareNoDatabase(t *testing.T) { cfg := &config.Config{JWTSecret: "test"} r := gin.New() diff --git a/server/middleware/admin.go b/server/middleware/admin.go new file mode 100644 index 0000000..1fc7b32 --- /dev/null +++ b/server/middleware/admin.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RequireAdmin returns middleware that restricts access to admin users. +// Must be used after Auth() middleware which sets "role" in context. +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "admin access required", + }) + return + } + c.Next() + } +} diff --git a/src/css/styles.css b/src/css/styles.css index 5a22fe9..55de521 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -850,3 +850,352 @@ body { min-height: 1.2rem; margin-top: 0.5rem; } + +/* ── App Hidden (splash gate active) ─────── */ + +.app-hidden { + display: none !important; +} + +#appContainer { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +/* ── Splash Gate ─────────────────────────── */ + +.splash-gate { + display: none; + position: fixed; + inset: 0; + z-index: 9999; + background: var(--bg-primary); + align-items: center; + justify-content: center; +} + +.splash-gate.visible { + display: flex; +} + +.splash-card { + width: 100%; + max-width: 400px; + padding: 2rem; +} + +.splash-brand { + text-align: center; + margin-bottom: 2rem; +} + +.splash-logo { + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.splash-brand h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.splash-tagline { + color: var(--text-secondary); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +.splash-form { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.5rem; +} + +.splash-form .auth-tabs { + margin-bottom: 1rem; +} + +.splash-actions { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.btn-full { + width: 100%; + justify-content: center; +} + +.splash-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.25rem 0; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.splash-divider::before, +.splash-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + + +/* ── Admin Panel ─────────────────────────── */ + +.admin-modal { + max-width: 700px; + width: 90vw; +} + +.admin-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.admin-tab { + padding: 0.5rem 1rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.9rem; +} + +.admin-tab:hover { color: var(--text-primary); } +.admin-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.admin-user-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border); +} + +.admin-user-row:last-child { border-bottom: none; } + +.admin-user-info { + flex: 1; + min-width: 0; +} + +.admin-user-name { + font-weight: 600; + display: block; +} + +.admin-user-email { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.admin-user-meta { + display: flex; + gap: 0.4rem; +} + +.admin-badge { + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.admin-badge-admin { background: #7c3aed22; color: #a78bfa; } +.admin-badge-user { background: #3b82f622; color: #60a5fa; } +.admin-badge-moderator { background: #f59e0b22; color: #fbbf24; } +.admin-badge-active { background: #10b98122; color: #34d399; } +.admin-badge-inactive { background: #ef444422; color: #f87171; } + +.admin-user-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.admin-role-select { + padding: 0.2rem 0.4rem; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.8rem; +} + +.admin-you { + color: var(--text-secondary); + font-size: 0.8rem; + font-style: italic; +} + +.btn-warning { background: #f59e0b; color: #000; } +.btn-danger { background: #ef4444; color: #fff; } +.btn-success { background: #10b981; color: #fff; } +.btn-small { padding: 0.2rem 0.5rem; font-size: 0.75rem; } + +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.75rem; +} + +.admin-stat-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + text-align: center; +} + +.admin-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); +} + +.admin-stat-label { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.admin-loading, .admin-empty, .admin-error { + padding: 1rem; + text-align: center; + color: var(--text-secondary); +} + +.admin-error { color: #ef4444; } + +.toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +/* ── Admin Add User / Reset Password ─────── */ + +.admin-add-user-toggle { + margin-bottom: 0.75rem; +} + +.admin-add-user-form { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 0.75rem; +} + +.admin-form-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.admin-form-row:last-child { + margin-bottom: 0; +} + +.admin-input { + flex: 1; + padding: 0.4rem 0.6rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.85rem; +} + +.admin-input:focus { + outline: none; + border-color: var(--accent); +} + +.admin-reset-dialog { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + border-radius: 8px; +} + +.admin-reset-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + min-width: 300px; +} + +.admin-reset-card h3 { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.admin-reset-card p { + color: var(--text-secondary); + font-size: 0.85rem; + margin: 0 0 0.75rem 0; +} + +.admin-reset-card .admin-input { + width: 100%; + margin-bottom: 0.75rem; + box-sizing: border-box; +} + +.admin-modal .modal-body { + position: relative; +} + +/* ── Profile Section in Settings ─────────── */ + +.settings-section-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--accent); + margin: 0 0 0.75rem 0; +} + +.settings-divider { + border: none; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + +.profile-password-row { + margin-top: 0.5rem; +} + +#profileChangePwForm { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; +} + +#profileChangePwForm .form-group { + margin-bottom: 0.5rem; +} diff --git a/src/index.html b/src/index.html index 34e912d..f4e039e 100644 --- a/src/index.html +++ b/src/index.html @@ -4,9 +4,10 @@ Chat Switchboard - + + + + + +
+ + + +
@@ -124,6 +158,7 @@ Your API key for authentication
+
@@ -198,14 +233,97 @@
- -
- - - - - - + + + + + + + diff --git a/src/js/admin.js b/src/js/admin.js new file mode 100644 index 0000000..9baf01b --- /dev/null +++ b/src/js/admin.js @@ -0,0 +1,273 @@ +// ========================================== +// Admin Panel +// ========================================== + +let _adminListenersInit = false; + +function initAdmin() { + if (Backend.user && Backend.user.role === 'admin') { + document.getElementById('adminBtn').style.display = ''; + } else { + document.getElementById('adminBtn').style.display = 'none'; + } +} + +function openAdmin() { + document.getElementById('adminModal').classList.add('active'); + initAdminListeners(); + switchAdminTab('users'); + loadAdminUsers(); +} + +function closeAdmin() { + document.getElementById('adminModal').classList.remove('active'); +} + +function initAdminListeners() { + if (_adminListenersInit) return; + _adminListenersInit = true; + + document.getElementById('adminShowAddUser').addEventListener('click', () => { + document.getElementById('adminAddUserForm').style.display = ''; + document.getElementById('adminShowAddUser').style.display = 'none'; + }); + document.getElementById('adminCancelAddUser').addEventListener('click', () => { + document.getElementById('adminAddUserForm').style.display = 'none'; + document.getElementById('adminShowAddUser').style.display = ''; + clearAddUserForm(); + }); + document.getElementById('adminCreateUserBtn').addEventListener('click', handleCreateUser); + document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog); + document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword); + + document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) { + if (e.key === 'Enter') handleResetPassword(); + }); +} + +function switchAdminTab(tab) { + document.querySelectorAll('.admin-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none'; + document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none'; + document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none'; + document.getElementById('adminResetDialog').style.display = 'none'; + + if (tab === 'settings') loadAdminSettings(); + if (tab === 'stats') loadAdminStats(); +} + +// ── Add User ──────────────────────────────── + +function clearAddUserForm() { + document.getElementById('adminNewUsername').value = ''; + document.getElementById('adminNewEmail').value = ''; + document.getElementById('adminNewPassword').value = ''; + document.getElementById('adminNewRole').value = 'user'; +} + +async function handleCreateUser() { + const username = document.getElementById('adminNewUsername').value.trim(); + const email = document.getElementById('adminNewEmail').value.trim(); + const password = document.getElementById('adminNewPassword').value; + const role = document.getElementById('adminNewRole').value; + + if (!username || !email || !password) { + showToast('⚠️ Fill in all fields', 'warning'); + return; + } + if (password.length < 8) { + showToast('⚠️ Password must be at least 8 characters', 'warning'); + return; + } + + try { + await Backend.adminCreateUser(username, email, password, role); + showToast('✅ User "' + username + '" created', 'success'); + clearAddUserForm(); + document.getElementById('adminAddUserForm').style.display = 'none'; + document.getElementById('adminShowAddUser').style.display = ''; + loadAdminUsers(); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +// ── Users List ────────────────────────────── + +async function loadAdminUsers() { + const container = document.getElementById('adminUserList'); + container.innerHTML = '
Loading users...
'; + + try { + const data = await Backend.adminListUsers(1, 100); + const users = data.data || []; + + if (users.length === 0) { + container.innerHTML = '
No users found
'; + return; + } + + container.innerHTML = users.map(u => { + const isSelf = u.id === Backend.user.id; + const actions = isSelf ? '(you)' : ` + + + + + `; + + return ` +
+ +
+ ${u.role} + ${u.is_active ? 'active' : 'disabled'} +
+ +
`; + }).join(''); + } catch (e) { + container.innerHTML = '
Failed to load users: ' + e.message + '
'; + } +} + +async function changeUserRole(userId, role) { + try { + await Backend.adminUpdateUserRole(userId, role); + showToast('✅ Role updated to ' + role, 'success'); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + loadAdminUsers(); + } +} + +async function toggleUserActive(userId, isActive) { + try { + await Backend.adminToggleUserActive(userId, isActive); + showToast('✅ User ' + (isActive ? 'enabled' : 'disabled'), 'success'); + loadAdminUsers(); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +async function deleteUser(userId, username) { + if (!confirm('Delete user "' + username + '"? This removes all their chats and data.')) return; + try { + await Backend.adminDeleteUser(userId); + showToast('✅ User deleted', 'success'); + loadAdminUsers(); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +// ── Reset Password ────────────────────────── + +let _resetUserId = null; + +function openResetDialog(userId, username) { + _resetUserId = userId; + document.getElementById('adminResetTarget').textContent = 'Set new password for: ' + username; + document.getElementById('adminResetNewPw').value = ''; + document.getElementById('adminResetDialog').style.display = ''; + document.getElementById('adminResetNewPw').focus(); +} + +function closeResetDialog() { + _resetUserId = null; + document.getElementById('adminResetDialog').style.display = 'none'; +} + +async function handleResetPassword() { + if (!_resetUserId) return; + const pw = document.getElementById('adminResetNewPw').value; + if (!pw || pw.length < 8) { + showToast('⚠️ Password must be at least 8 characters', 'warning'); + return; + } + + try { + await Backend.adminResetPassword(_resetUserId, pw); + showToast('✅ Password reset', 'success'); + closeResetDialog(); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +// ── Settings ──────────────────────────────── + +async function loadAdminSettings() { + try { + const data = await Backend.adminListSettings(); + const settings = data.settings || []; + + for (const s of settings) { + if (s.key === 'registration') { + document.getElementById('adminRegToggle').checked = s.value.enabled !== false; + } + if (s.key === 'site') { + document.getElementById('adminSiteName').value = s.value.name || 'Chat Switchboard'; + document.getElementById('adminTagline').value = s.value.tagline || 'Multi-Model AI Chat'; + } + } + } catch (e) { + showToast('❌ Failed to load settings: ' + e.message, 'error'); + } +} + +async function saveAdminSettings() { + try { + await Backend.adminUpdateSetting('registration', { + enabled: document.getElementById('adminRegToggle').checked + }); + await Backend.adminUpdateSetting('site', { + name: document.getElementById('adminSiteName').value.trim(), + tagline: document.getElementById('adminTagline').value.trim() + }); + showToast('✅ Admin settings saved', 'success'); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +// ── Stats ─────────────────────────────────── + +async function loadAdminStats() { + const container = document.getElementById('adminStats'); + container.innerHTML = '
Loading stats...
'; + + try { + const stats = await Backend.adminGetStats(); + container.innerHTML = '
' + + statCard(stats.total_users, 'Total Users') + + statCard(stats.active_users, 'Active Users') + + statCard(stats.total_chats, 'Chats') + + statCard(stats.total_messages, 'Messages') + + statCard(stats.api_configs, 'API Configs') + + '
'; + } catch (e) { + container.innerHTML = '
Failed to load stats: ' + e.message + '
'; + } +} + +function statCard(value, label) { + return '
' + + (value || 0) + '
' + label + '
'; +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/src/js/app.js b/src/js/app.js index b2f42d9..b589eb3 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -10,10 +10,11 @@ async function init() { Backend.init(); // Auto-detect backend at same origin + let health = null; if (!Backend.baseUrl) { - const health = await Backend.checkHealth(''); + health = await Backend.checkHealth(''); if (health && health.database) { - Backend.baseUrl = window.location.origin; // same origin + Backend.baseUrl = window.location.origin; Backend.save(); console.log('🔗 Backend detected at same origin'); } @@ -21,27 +22,68 @@ async function init() { // If we have saved auth, validate it if (Backend.accessToken) { - const health = await Backend.checkHealth(Backend.baseUrl); + health = health || await Backend.checkHealth(Backend.baseUrl); if (!health) { console.log('⚠️ Backend unreachable, falling back to unmanaged'); Backend.clear(); } } + // Decide: splash gate or straight to app + if (Backend.baseUrl && !Backend.accessToken) { + // Online but not authenticated → show splash, hide app + showSplashGate(health); + initAuthListeners(); + return; // Don't init app yet — authSuccess() will do it + } + + // Already authed or no backend → go straight to app + hideSplashGate(); + await initApp(); +} + +async function initApp() { await loadChats(); initializeEventListeners(); updateQuickModelSelector(); renderChatHistory(); updateConnectionStatus(); - - // If backend available but not logged in, show auth - if (Backend.baseUrl && !Backend.accessToken) { - showAuthModal(); - } - + initAdmin(); console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`); } +function showSplashGate(health) { + const splash = document.getElementById('splashGate'); + const app = document.getElementById('appContainer'); + splash.classList.add('visible'); + splash.style.display = 'flex'; // Fallback for cached CSS + app.classList.add('app-hidden'); + app.style.display = 'none'; // Fallback for cached CSS + + // Hide register tab if registration disabled + const registerTab = document.getElementById('authTabRegister'); + if (health && health.registration_enabled === false) { + registerTab.style.display = 'none'; + } else { + registerTab.style.display = ''; + } +} + +function hideSplashGate() { + const splash = document.getElementById('splashGate'); + const app = document.getElementById('appContainer'); + splash.classList.remove('visible'); + splash.style.display = 'none'; // Hide splash + app.classList.remove('app-hidden'); + app.style.display = ''; // Show app +} + +async function authSuccess() { + hideSplashGate(); + await initApp(); + showToast(`✅ Welcome, ${Backend.user?.username || 'user'}!`, 'success'); +} + function initializeEventListeners() { // Settings modal document.getElementById('settingsBtn').addEventListener('click', openSettings); @@ -51,6 +93,12 @@ function initializeEventListeners() { document.getElementById('saveBtn').addEventListener('click', handleSaveSettings); document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true)); + // Profile + document.getElementById('profileChangePwBtn').addEventListener('click', () => { + document.getElementById('profileChangePwForm').style.display = ''; + }); + document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword); + // Chat controls document.getElementById('newChatBtn').addEventListener('click', newChat); document.getElementById('sendBtn').addEventListener('click', sendMessage); @@ -97,26 +145,21 @@ function initializeEventListeners() { document.getElementById('settingsModal').addEventListener('click', function(e) { if (e.target === this) closeSettings(); }); - document.getElementById('authModal').addEventListener('click', function(e) { - if (e.target === this) { - // Only closeable if user has option to skip (unmanaged available) - if (!Backend.baseUrl || Backend.accessToken) { - closeAuthModal(); - } - } - }); - - // Auth modal - document.getElementById('authLoginBtn').addEventListener('click', handleLogin); - document.getElementById('authRegisterBtn').addEventListener('click', handleRegister); - document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login')); - document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register')); - document.getElementById('authCloseBtn').addEventListener('click', closeAuthModal); - document.getElementById('authSkipBtn').addEventListener('click', skipAuth); // Connection status click document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick); + // Admin panel + document.getElementById('adminBtn').addEventListener('click', openAdmin); + document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin); + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab)); + }); + document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings); + document.getElementById('adminModal').addEventListener('click', function(e) { + if (e.target === this) closeAdmin(); + }); + // Global keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.ctrlKey && e.key === ',') { @@ -132,20 +175,6 @@ function initializeEventListeners() { stopGeneration(); } else if (document.getElementById('settingsModal').classList.contains('active')) { closeSettings(); - } else if (document.getElementById('authModal').classList.contains('active')) { - closeAuthModal(); - } - } - }); - - // Enter key in auth form - document.getElementById('authPassword').addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - const activeTab = document.querySelector('.auth-tab.active'); - if (activeTab && activeTab.id === 'authTabRegister') { - // Focus email field first if registering - } else { - handleLogin(); } } }); @@ -176,6 +205,7 @@ function handleSaveSettings() { State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0; saveSettings(); + saveProfile(); // async, fire-and-forget updateQuickModelSelector(); closeSettings(); showToast('✅ Settings saved', 'success'); @@ -307,18 +337,24 @@ async function regenerateResponse() { // ── Auth Flow ─────────────────────────────── -function showAuthModal() { - document.getElementById('authModal').classList.add('active'); - document.getElementById('authLogin').value = ''; - document.getElementById('authPassword').value = ''; - document.getElementById('authUsername').value = ''; - document.getElementById('authEmail').value = ''; - document.getElementById('authError').textContent = ''; - switchAuthTab('login'); -} +let _authListenersInit = false; +function initAuthListeners() { + if (_authListenersInit) return; + _authListenersInit = true; -function closeAuthModal() { - document.getElementById('authModal').classList.remove('active'); + document.getElementById('authLoginBtn').addEventListener('click', handleLogin); + document.getElementById('authRegisterBtn').addEventListener('click', handleRegister); + document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login')); + document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register')); + document.getElementById('authSkipBtn').addEventListener('click', skipAuth); + + // Enter key in auth forms + document.getElementById('authPassword').addEventListener('keydown', function(e) { + if (e.key === 'Enter') handleLogin(); + }); + document.getElementById('authRegPassword').addEventListener('keydown', function(e) { + if (e.key === 'Enter') handleRegister(); + }); } function switchAuthTab(tab) { @@ -343,11 +379,7 @@ async function handleLogin() { setAuthLoading(true); try { await Backend.login(login, password); - closeAuthModal(); - await loadChats(); - renderChatHistory(); - updateConnectionStatus(); - showToast(`✅ Welcome back, ${Backend.user.username}`, 'success'); + await authSuccess(); } catch (e) { document.getElementById('authError').textContent = e.message; } finally { @@ -372,11 +404,7 @@ async function handleRegister() { setAuthLoading(true); try { await Backend.register(username, email, password); - closeAuthModal(); - await loadChats(); - renderChatHistory(); - updateConnectionStatus(); - showToast(`✅ Welcome, ${Backend.user.username}!`, 'success'); + await authSuccess(); } catch (e) { document.getElementById('authError').textContent = e.message; } finally { @@ -393,10 +421,10 @@ function setAuthLoading(loading) { }); } -function skipAuth() { - closeAuthModal(); +async function skipAuth() { Backend.clear(); - updateConnectionStatus(); + hideSplashGate(); + await initApp(); showToast('📴 Running in offline mode', 'success'); } @@ -406,7 +434,7 @@ function updateConnectionStatus() { const el = document.getElementById('connectionStatus'); if (Backend.isManaged) { el.className = 'connection-status managed'; - el.innerHTML = ` ${Backend.user?.username || 'Connected'}`; + el.innerHTML = ` ${Backend.user?.display_name || Backend.user?.username || 'Connected'}`; el.title = 'Managed mode – click to sign out'; } else if (Backend.baseUrl) { el.className = 'connection-status available'; @@ -425,14 +453,13 @@ async function handleConnectionClick() { await Backend.logout(); State.chats = []; State.currentChatId = null; - loadChats(); - newChat(); - renderChatHistory(); - updateConnectionStatus(); + showSplashGate(null); + initAuthListeners(); showToast('👋 Signed out', 'success'); } } else if (Backend.baseUrl) { - showAuthModal(); + showSplashGate(null); + initAuthListeners(); } } diff --git a/src/js/backend.js b/src/js/backend.js index 42a64fa..cefd1c9 100644 --- a/src/js/backend.js +++ b/src/js/backend.js @@ -201,6 +201,99 @@ const Backend = { }); }, + // ── Profile & Settings ────────────────── + + async getProfile() { + return this._authedFetch('/api/v1/profile'); + }, + + async updateProfile(updates) { + return this._authedFetch('/api/v1/profile', { + method: 'PUT', + body: JSON.stringify(updates) + }); + }, + + async changePassword(currentPassword, newPassword) { + return this._authedFetch('/api/v1/profile/password', { + method: 'POST', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + }, + + async getSettings() { + return this._authedFetch('/api/v1/settings'); + }, + + async updateSettings(settings) { + return this._authedFetch('/api/v1/settings', { + method: 'PUT', + body: JSON.stringify(settings) + }); + }, + + // ── Admin ─────────────────────────────── + + async adminListUsers(page = 1, perPage = 50) { + return this._authedFetch(`/api/v1/admin/users?page=${page}&per_page=${perPage}`); + }, + + async adminCreateUser(username, email, password, role) { + return this._authedFetch('/api/v1/admin/users', { + method: 'POST', + body: JSON.stringify({ username, email, password, role }) + }); + }, + + async adminResetPassword(userId, newPassword) { + return this._authedFetch(`/api/v1/admin/users/${userId}/reset-password`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }) + }); + }, + + async adminUpdateUserRole(userId, role) { + return this._authedFetch(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + body: JSON.stringify({ role }) + }); + }, + + async adminToggleUserActive(userId, isActive) { + return this._authedFetch(`/api/v1/admin/users/${userId}/active`, { + method: 'PUT', + body: JSON.stringify({ is_active: isActive }) + }); + }, + + async adminDeleteUser(userId) { + return this._authedFetch(`/api/v1/admin/users/${userId}`, { + method: 'DELETE' + }); + }, + + async adminListSettings() { + return this._authedFetch('/api/v1/admin/settings'); + }, + + async adminGetSetting(key) { + return this._authedFetch(`/api/v1/admin/settings/${key}`); + }, + + async adminUpdateSetting(key, value) { + return this._authedFetch(`/api/v1/admin/settings/${key}`, { + method: 'PUT', + body: JSON.stringify(value) + }); + }, + + async adminGetStats() { + return this._authedFetch('/api/v1/admin/stats'); + }, + // ── Models ────────────────────────────── async listAllModels() { diff --git a/src/js/ui.js b/src/js/ui.js index 3025c76..e28bef0 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -88,9 +88,88 @@ function updateQuickModelSelector() { function openSettings() { updateSettingsUI(); + updateProfileSection(); document.getElementById('settingsModal').classList.add('active'); } +function updateProfileSection() { + const profileSection = document.getElementById('profileSection'); + const unmanagedSettings = document.getElementById('unmanagedSettings'); + + if (Backend.isManaged) { + profileSection.style.display = ''; + unmanagedSettings.style.display = 'none'; + loadProfileIntoSettings(); + } else { + profileSection.style.display = 'none'; + unmanagedSettings.style.display = ''; + } + // Always reset password form + document.getElementById('profileChangePwForm').style.display = 'none'; +} + +async function loadProfileIntoSettings() { + try { + const profile = await Backend.getProfile(); + document.getElementById('profileDisplayName').value = profile.display_name || ''; + document.getElementById('profileEmail').value = profile.email || ''; + } catch (e) { + console.warn('Failed to load profile:', e); + } +} + +async function saveProfile() { + if (!Backend.isManaged) return; + + const displayName = document.getElementById('profileDisplayName').value.trim(); + const email = document.getElementById('profileEmail').value.trim(); + + const updates = {}; + if (displayName !== (Backend.user.display_name || '')) { + updates.display_name = displayName || null; + } + if (email && email !== Backend.user.email) { + updates.email = email; + } + + if (Object.keys(updates).length === 0) return; + + try { + const profile = await Backend.updateProfile(updates); + // Update local user state + if (profile.display_name !== undefined) Backend.user.display_name = profile.display_name; + if (profile.email) Backend.user.email = profile.email; + Backend.save(); + showToast('✅ Profile updated', 'success'); + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + +async function handleChangePassword() { + const current = document.getElementById('profileCurrentPw').value; + const newPw = document.getElementById('profileNewPw').value; + + if (!current || !newPw) { + showToast('⚠️ Fill in both fields', 'warning'); + return; + } + if (newPw.length < 8) { + showToast('⚠️ New password must be at least 8 characters', 'warning'); + return; + } + + try { + await Backend.changePassword(current, newPw); + showToast('✅ Password updated', 'success'); + document.getElementById('profileCurrentPw').value = ''; + document.getElementById('profileNewPw').value = ''; + document.getElementById('profileChangePwForm').style.display = 'none'; + } catch (e) { + showToast('❌ ' + e.message, 'error'); + } +} + function closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }