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"` Role string `json:"role"` 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, Role: user.Role, 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() }