Feature user and admin panels (#30)
This commit is contained in:
15
migrations/003_global_settings.sql
Normal file
15
migrations/003_global_settings.sql
Normal file
@@ -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;
|
||||
386
server/handlers/admin.go
Normal file
386
server/handlers/admin.go
Normal file
@@ -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)
|
||||
}
|
||||
27
server/handlers/admin_test.go
Normal file
27
server/handlers/admin_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
208
server/handlers/settings.go
Normal file
208
server/handlers/settings.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
22
server/middleware/admin.go
Normal file
22
server/middleware/admin.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
152
src/index.html
152
src/index.html
@@ -4,9 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.3.4">
|
||||
</head>
|
||||
<body>
|
||||
<div id="appContainer" class="app-hidden" style="display:none">
|
||||
<header class="header">
|
||||
<h1>🔀 Chat Switchboard</h1>
|
||||
<div class="header-actions">
|
||||
@@ -29,6 +30,9 @@
|
||||
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary admin-only" id="adminBtn" title="Admin Panel" style="display:none">
|
||||
🛡️ Admin
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="settingsBtn" title="Settings (Ctrl+,)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
@@ -113,6 +117,36 @@
|
||||
<button class="modal-close" id="closeModalBtn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Profile (managed mode only) -->
|
||||
<div class="settings-section" id="profileSection" style="display:none">
|
||||
<h3 class="settings-section-title">Profile</h3>
|
||||
<div class="form-group">
|
||||
<label for="profileDisplayName">Display Name</label>
|
||||
<input type="text" id="profileDisplayName" placeholder="How you want to be called">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profileEmail">Email</label>
|
||||
<input type="email" id="profileEmail" placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="profile-password-row">
|
||||
<button class="btn btn-secondary btn-small" id="profileChangePwBtn">🔑 Change Password</button>
|
||||
</div>
|
||||
<div id="profileChangePwForm" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="profileCurrentPw">Current Password</label>
|
||||
<input type="password" id="profileCurrentPw" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profileNewPw">New Password</label>
|
||||
<input type="password" id="profileNewPw" placeholder="At least 8 characters">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-small" id="profileSavePwBtn">Update Password</button>
|
||||
</div>
|
||||
<hr class="settings-divider">
|
||||
</div>
|
||||
|
||||
<!-- API Settings (unmanaged / offline only) -->
|
||||
<div id="unmanagedSettings">
|
||||
<div class="form-group">
|
||||
<label for="apiEndpoint">API Endpoint</label>
|
||||
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
|
||||
@@ -124,6 +158,7 @@
|
||||
<input type="password" id="apiKey" placeholder="sk-...">
|
||||
<small>Your API key for authentication</small>
|
||||
</div>
|
||||
</div> <!-- /unmanagedSettings -->
|
||||
|
||||
<div class="form-group">
|
||||
<label for="model">Default Model</label>
|
||||
@@ -198,14 +233,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<div class="modal-overlay" id="authModal">
|
||||
<div class="modal auth-modal">
|
||||
<!-- Admin Panel Modal -->
|
||||
<div class="modal-overlay" id="adminModal">
|
||||
<div class="modal admin-modal">
|
||||
<div class="modal-header">
|
||||
<h2>🔀 Chat Switchboard</h2>
|
||||
<button class="modal-close" id="authCloseBtn">✕</button>
|
||||
<h2>🛡️ Admin Panel</h2>
|
||||
<button class="modal-close" id="adminCloseBtn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="users">Users</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div class="admin-tab-content active" id="adminUsersTab">
|
||||
<div class="admin-add-user-toggle">
|
||||
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
|
||||
</div>
|
||||
<div class="admin-add-user-form" id="adminAddUserForm" style="display:none">
|
||||
<div class="admin-form-row">
|
||||
<input type="text" id="adminNewUsername" placeholder="Username" class="admin-input">
|
||||
<input type="email" id="adminNewEmail" placeholder="Email" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-row">
|
||||
<input type="password" id="adminNewPassword" placeholder="Password (min 8)" class="admin-input">
|
||||
<select id="adminNewRole" class="admin-role-select">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="moderator">moderator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-row">
|
||||
<button class="btn btn-primary btn-small" id="adminCreateUserBtn">Create</button>
|
||||
<button class="btn btn-secondary btn-small" id="adminCancelAddUser">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="adminUserList" class="admin-user-list">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Dialog (inline) -->
|
||||
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
|
||||
<div class="admin-reset-card">
|
||||
<h3>Reset Password</h3>
|
||||
<p id="adminResetTarget"></p>
|
||||
<input type="password" id="adminResetNewPw" placeholder="New password (min 8)" class="admin-input">
|
||||
<div class="admin-form-row">
|
||||
<button class="btn btn-primary btn-small" id="adminResetConfirmBtn">Reset</button>
|
||||
<button class="btn btn-secondary btn-small" id="adminResetCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="adminRegToggle" checked>
|
||||
<span>Allow new user registration</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminSiteName">Site Name</label>
|
||||
<input type="text" id="adminSiteName" value="Chat Switchboard">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminTagline">Tagline</label>
|
||||
<input type="text" id="adminTagline" value="Multi-Model AI Chat">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="adminSaveSettings">💾 Save</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Tab -->
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none">
|
||||
<div id="adminStats" class="admin-stats">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /appContainer -->
|
||||
|
||||
<!-- ── Auth Splash Gate ────────────────── -->
|
||||
<div class="splash-gate" id="splashGate" style="display:none">
|
||||
<div class="splash-card">
|
||||
<div class="splash-brand">
|
||||
<div class="splash-logo">🔀</div>
|
||||
<h1>Chat Switchboard</h1>
|
||||
<p class="splash-tagline">Multi-Model AI Chat</p>
|
||||
</div>
|
||||
<div class="splash-form">
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" id="authTabLogin">Sign In</button>
|
||||
<button class="auth-tab" id="authTabRegister">Register</button>
|
||||
@@ -235,22 +353,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-error" id="authError"></div>
|
||||
<div class="splash-actions">
|
||||
<button class="btn btn-primary btn-full" id="authLoginBtn">Sign In</button>
|
||||
<button class="btn btn-primary btn-full" id="authRegisterBtn" style="display:none;">Create Account</button>
|
||||
<div class="splash-divider"><span>or</span></div>
|
||||
<button class="btn btn-secondary btn-full" id="authSkipBtn">Use Offline</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="authSkipBtn">Use Offline</button>
|
||||
<button class="btn btn-primary" id="authLoginBtn">Sign In</button>
|
||||
<button class="btn btn-primary" id="authRegisterBtn" style="display:none;">Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script src="js/storage.js"></script>
|
||||
<script src="js/backend.js"></script>
|
||||
<script src="js/state.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/storage.js?v=0.3.4"></script>
|
||||
<script src="js/backend.js?v=0.3.4"></script>
|
||||
<script src="js/state.js?v=0.3.4"></script>
|
||||
<script src="js/api.js?v=0.3.4"></script>
|
||||
<script src="js/ui.js?v=0.3.4"></script>
|
||||
<script src="js/admin.js?v=0.3.4"></script>
|
||||
<script src="js/app.js?v=0.3.4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
273
src/js/admin.js
Normal file
273
src/js/admin.js
Normal file
@@ -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 = '<div class="admin-loading">Loading users...</div>';
|
||||
|
||||
try {
|
||||
const data = await Backend.adminListUsers(1, 100);
|
||||
const users = data.data || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div class="admin-empty">No users found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = users.map(u => {
|
||||
const isSelf = u.id === Backend.user.id;
|
||||
const actions = isSelf ? '<span class="admin-you">(you)</span>' : `
|
||||
<select class="admin-role-select" onchange="changeUserRole('${u.id}', this.value)">
|
||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
||||
<option value="moderator" ${u.role === 'moderator' ? 'selected' : ''}>moderator</option>
|
||||
</select>
|
||||
<button class="btn btn-small btn-secondary" onclick="openResetDialog('${u.id}', '${escapeHtml(u.username)}')" title="Reset password">🔑</button>
|
||||
<button class="btn btn-small ${u.is_active ? 'btn-warning' : 'btn-success'}" onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteUser('${u.id}', '${escapeHtml(u.username)}')">Delete</button>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="admin-user-row" data-id="${u.id}">
|
||||
<div class="admin-user-info">
|
||||
<span class="admin-user-name">${escapeHtml(u.username)}</span>
|
||||
<span class="admin-user-email">${escapeHtml(u.email)}</span>
|
||||
</div>
|
||||
<div class="admin-user-meta">
|
||||
<span class="admin-badge admin-badge-${u.role}">${u.role}</span>
|
||||
<span class="admin-badge ${u.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}">${u.is_active ? 'active' : 'disabled'}</span>
|
||||
</div>
|
||||
<div class="admin-user-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load users: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="admin-loading">Loading stats...</div>';
|
||||
|
||||
try {
|
||||
const stats = await Backend.adminGetStats();
|
||||
container.innerHTML = '<div class="admin-stats-grid">' +
|
||||
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') +
|
||||
'</div>';
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load stats: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function statCard(value, label) {
|
||||
return '<div class="admin-stat-card"><div class="admin-stat-value">' +
|
||||
(value || 0) + '</div><div class="admin-stat-label">' + label + '</div></div>';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
163
src/js/app.js
163
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,25 +22,66 @@ 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)`);
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -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 = `<span class="status-dot"></span> ${Backend.user?.username || 'Connected'}`;
|
||||
el.innerHTML = `<span class="status-dot"></span> ${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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
79
src/js/ui.js
79
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user