This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/settings.go
Jeffrey Smith 5836ddad21
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / build-and-deploy (push) Has been cancelled
CI/CD / test-sqlite (push) Has been cancelled
CI/CD / test-go-pg (push) Has been cancelled
Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-26 17:30:36 +00:00

258 lines
7.2 KiB
Go

package handlers
// settings.go — User profile, preferences, and password management.
//
// v0.29.0: Raw SQL replaced with UserStore methods.
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"switchboard-core/crypto"
"switchboard-core/store"
)
// ── 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"`
Avatar *string `json:"avatar,omitempty"`
Settings map[string]interface{} `json:"settings"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// SettingsHandler manages user profile and preferences.
type SettingsHandler struct {
stores store.Stores
uekCache *crypto.UEKCache
}
// NewSettingsHandler creates a new handler.
func NewSettingsHandler(stores store.Stores, uekCache *crypto.UEKCache) *SettingsHandler {
return &SettingsHandler{stores: stores, uekCache: uekCache}
}
// ── Get Profile ─────────────────────────────
func (h *SettingsHandler) GetProfile(c *gin.Context) {
userID := getUserID(c)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var dn *string
if user.DisplayName != "" {
dn = &user.DisplayName
}
var avatar *string
if user.AvatarURL != "" {
avatar = &user.AvatarURL
}
settings := make(map[string]interface{})
if user.Settings != nil {
settings = user.Settings
}
c.JSON(http.StatusOK, profileResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
DisplayName: dn,
Avatar: avatar,
Settings: settings,
CreatedAt: user.CreatedAt,
LastLoginAt: user.LastLoginAt,
})
}
// ── 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 {
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"display_name": *req.DisplayName,
}); 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 := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"email": email,
})
if err != nil {
if isDuplicateErr(err) {
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
}
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"password_hash": string(newHash),
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"})
return
}
h.rewrapVault(userID, req.CurrentPassword, req.NewPassword)
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// ── Get User Settings (JSONB) ───────────────
func (h *SettingsHandler) GetSettings(c *gin.Context) {
userID := getUserID(c)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
settings := make(map[string]interface{})
if user.Settings != nil {
settings = user.Settings
}
c.JSON(http.StatusOK, gin.H{"settings": 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
}
if err := h.stores.Users.MergeSettings(c.Request.Context(), userID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
return
}
h.GetSettings(c)
}
// ── Vault Re-wrap ──────────────────────────
func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
if h.uekCache == nil {
return
}
vaultSet, encUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(c_bg(), userID)
if err != nil || !vaultSet || len(encUEK) == 0 {
return
}
oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt)
uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK)
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (unwrap): %v", userID, err)
return
}
newSalt, err := crypto.GenerateSalt()
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err)
return
}
newPDK := crypto.DeriveKeyFromPassword(newPassword, newSalt)
newEncUEK, newNonce, err := crypto.WrapUEK(uek, newPDK)
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (wrap): %v", userID, err)
return
}
if err := h.stores.Users.UpdateVaultKeys(c_bg(), userID, newEncUEK, newSalt, newNonce); err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err)
return
}
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault re-wrapped for user %s", userID)
}
// c_bg returns context.Background() — short alias for vault operations
// that run outside the request lifecycle.
func c_bg() context.Context { return context.Background() }