Feature user and admin panels (#30)
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user