Feature user and admin panels (#30)

This commit is contained in:
2026-02-16 20:30:36 +00:00
parent c5866ae785
commit 2fc4f6980c
15 changed files with 1805 additions and 111 deletions

386
server/handlers/admin.go Normal file
View 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)
}

View 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")
}
}

View File

@@ -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
View 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)
}