package handlers import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/utils" ) type UserHandler struct { DB *gorm.DB AuthService *services.AuthService MailService *services.MailService securitySvc *services.SecurityService } func NewUserHandler(db *gorm.DB, authService *services.AuthService) *UserHandler { return &UserHandler{ DB: db, AuthService: authService, MailService: services.NewMailService(db), securitySvc: services.NewSecurityService(db), } } func (h *UserHandler) actorFromContext(c *gin.Context) string { if userID, ok := c.Get("userID"); ok { return fmt.Sprintf("%v", userID) } return c.ClientIP() } func (h *UserHandler) logUserAudit(c *gin.Context, action string, user *models.User, details map[string]any) { if h.securitySvc == nil || user == nil { return } detailsJSON, err := json.Marshal(details) if err != nil { detailsJSON = []byte("{}") } _ = h.securitySvc.LogAudit(&models.SecurityAudit{ Actor: h.actorFromContext(c), Action: action, EventCategory: "user", ResourceID: &user.ID, ResourceUUID: user.UUID, Details: string(detailsJSON), IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), }) } func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { r.GET("/setup", h.GetSetupStatus) r.POST("/setup", h.Setup) r.GET("/profile", h.GetProfile) r.POST("/regenerate-api-key", h.RegenerateAPIKey) r.PUT("/profile", h.UpdateProfile) // User management (admin only) r.GET("/users", h.ListUsers) r.POST("/users", h.CreateUser) r.POST("/users/invite", h.InviteUser) r.GET("/users/:id", h.GetUser) r.PUT("/users/:id", h.UpdateUser) r.DELETE("/users/:id", h.DeleteUser) r.PUT("/users/:id/permissions", h.UpdateUserPermissions) // Invite acceptance (public) r.GET("/invite/validate", h.ValidateInvite) r.POST("/invite/accept", h.AcceptInvite) } // GetSetupStatus checks if the application needs initial setup (i.e., no users exist). func (h *UserHandler) GetSetupStatus(c *gin.Context) { var count int64 if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) return } c.JSON(http.StatusOK, gin.H{ "setupRequired": count == 0, }) } type SetupRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` } func isSetupConflictError(err error) bool { if err == nil { return false } errText := strings.ToLower(err.Error()) return strings.Contains(errText, "unique constraint failed") || strings.Contains(errText, "duplicate key") || strings.Contains(errText, "database is locked") || strings.Contains(errText, "database table is locked") } // Setup creates the initial admin user and configures the ACME email. func (h *UserHandler) Setup(c *gin.Context) { // 1. Check if setup is allowed var count int64 if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) return } if count > 0 { c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"}) return } // 2. Parse request var req SetupRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 3. Create User user := models.User{ UUID: uuid.New().String(), Name: req.Name, Email: strings.ToLower(req.Email), Role: models.RoleAdmin, Enabled: true, APIKey: uuid.New().String(), } if err := user.SetPassword(req.Password); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } // 4. Create Setting for ACME Email acmeEmailSetting := models.Setting{ Key: "caddy.acme_email", Value: req.Email, Type: "string", Category: "caddy", } // Transaction to ensure both succeed err := h.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return err } // Use Save to update if exists (though it shouldn't in fresh setup) or create if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil { return err } return nil }) if err != nil { var postTxCount int64 if countErr := h.DB.Model(&models.User{}).Count(&postTxCount).Error; countErr == nil && postTxCount > 0 { c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"}) return } if isSetupConflictError(err) { c.JSON(http.StatusConflict, gin.H{"error": "Setup conflict: setup already in progress or completed"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "message": "Setup completed successfully", "user": gin.H{ "id": user.ID, "email": user.Email, "name": user.Name, }, }) } // rejectPassthrough aborts with 403 if the caller is a passthrough user. // Returns true if the request was rejected (caller should return). func rejectPassthrough(c *gin.Context, action string) bool { if c.GetString("role") == string(models.RolePassthrough) { c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action}) return true } return false } // RegenerateAPIKey generates a new API key for the authenticated user. func (h *UserHandler) RegenerateAPIKey(c *gin.Context) { if rejectPassthrough(c, "manage API keys") { return } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } apiKey := uuid.New().String() if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"}) return } c.JSON(http.StatusOK, gin.H{ "message": "API key regenerated successfully", "has_api_key": true, "api_key_masked": maskSecretForResponse(apiKey), "api_key_updated": time.Now().UTC().Format(time.RFC3339), }) } // GetProfile returns the current user's profile including API key. func (h *UserHandler) GetProfile(c *gin.Context) { if rejectPassthrough(c, "access profile") { return } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } var user models.User if err := h.DB.First(&user, userID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } c.JSON(http.StatusOK, gin.H{ "id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role, "has_api_key": strings.TrimSpace(user.APIKey) != "", "api_key_masked": maskSecretForResponse(user.APIKey), }) } type UpdateProfileRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` CurrentPassword string `json:"current_password"` } // UpdateProfile updates the authenticated user's profile. func (h *UserHandler) UpdateProfile(c *gin.Context) { if rejectPassthrough(c, "update profile") { return } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } var req UpdateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get current user var user models.User if err := h.DB.First(&user, userID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } // Check if email is already taken by another user req.Email = strings.ToLower(req.Email) var count int64 if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"}) return } if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) return } // If email is changing, verify password if req.Email != user.Email { if req.CurrentPassword == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"}) return } if !user.CheckPassword(req.CurrentPassword) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) return } } if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]any{ "name": req.Name, "email": req.Email, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) return } c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"}) } // ListUsers returns all users (admin only). func (h *UserHandler) ListUsers(c *gin.Context) { if !requireAdmin(c) { return } var users []models.User if err := h.DB.Preload("PermittedHosts").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"}) return } // Return users with safe fields only result := make([]gin.H, len(users)) for i, u := range users { result[i] = gin.H{ "id": u.ID, "uuid": u.UUID, "email": u.Email, "name": u.Name, "role": u.Role, "enabled": u.Enabled, "last_login": u.LastLogin, "invite_status": u.InviteStatus, "invited_at": u.InvitedAt, "permission_mode": u.PermissionMode, "created_at": u.CreatedAt, "updated_at": u.UpdatedAt, } } c.JSON(http.StatusOK, result) } // CreateUserRequest represents the request body for creating a user. type CreateUserRequest struct { Email string `json:"email" binding:"required,email"` Name string `json:"name" binding:"required"` Password string `json:"password" binding:"required,min=8"` Role string `json:"role"` PermissionMode string `json:"permission_mode"` PermittedHosts []uint `json:"permitted_hosts"` } // CreateUser creates a new user with a password (admin only). func (h *UserHandler) CreateUser(c *gin.Context) { if !requireAdmin(c) { return } var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Default role to "user" if req.Role == "" { req.Role = string(models.RoleUser) } if !models.UserRole(req.Role).IsValid() { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"}) return } // Default permission mode to "allow_all" if req.PermissionMode == "" { req.PermissionMode = "allow_all" } // Check if email already exists var count int64 if err := h.DB.Model(&models.User{}).Where("email = ?", strings.ToLower(req.Email)).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email"}) return } if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) return } user := models.User{ UUID: uuid.New().String(), Email: strings.ToLower(req.Email), Name: req.Name, Role: models.UserRole(req.Role), Enabled: true, APIKey: uuid.New().String(), PermissionMode: models.PermissionMode(req.PermissionMode), } if err := user.SetPassword(req.Password); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } err := h.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return err } // Add permitted hosts if specified if len(req.PermittedHosts) > 0 { var hosts []models.ProxyHost if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil { return err } if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil { return err } } return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) return } h.logUserAudit(c, "user_create", &user, map[string]any{ "target_email": user.Email, "target_role": user.Role, }) c.JSON(http.StatusCreated, gin.H{ "id": user.ID, "uuid": user.UUID, "email": user.Email, "name": user.Name, "role": user.Role, }) } // InviteUserRequest represents the request body for inviting a user. type InviteUserRequest struct { Email string `json:"email" binding:"required,email"` Role string `json:"role"` PermissionMode string `json:"permission_mode"` PermittedHosts []uint `json:"permitted_hosts"` } // generateSecureToken creates a cryptographically secure random token. func generateSecureToken(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } // InviteUser creates a new user with an invite token and sends an email (admin only). func (h *UserHandler) InviteUser(c *gin.Context) { if !requireAdmin(c) { return } inviterID, _ := c.Get("userID") var req InviteUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Default role to "user" if req.Role == "" { req.Role = string(models.RoleUser) } if !models.UserRole(req.Role).IsValid() { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"}) return } // Default permission mode to "allow_all" if req.PermissionMode == "" { req.PermissionMode = "allow_all" } // Check if email already exists var existingUser models.User if err := h.DB.Where("email = ?", strings.ToLower(req.Email)).First(&existingUser).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) return } // Generate invite token inviteToken, err := generateSecureToken(32) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"}) return } // Set invite expiration (48 hours) inviteExpires := time.Now().Add(48 * time.Hour) invitedAt := time.Now() inviterIDUint := inviterID.(uint) user := models.User{ UUID: uuid.New().String(), Email: strings.ToLower(req.Email), Role: models.UserRole(req.Role), Enabled: false, // Disabled until invite is accepted APIKey: uuid.New().String(), PermissionMode: models.PermissionMode(req.PermissionMode), InviteToken: inviteToken, InviteExpires: &inviteExpires, InvitedAt: &invitedAt, InvitedBy: &inviterIDUint, InviteStatus: "pending", } err = h.DB.Transaction(func(tx *gorm.DB) error { if txErr := tx.Create(&user).Error; txErr != nil { return txErr } // Explicitly disable user (bypass GORM's default:true) if txErr := tx.Model(&user).Update("enabled", false).Error; txErr != nil { return txErr } // Add permitted hosts if specified if len(req.PermittedHosts) > 0 { var hosts []models.ProxyHost if findErr := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; findErr != nil { return findErr } if assocErr := tx.Model(&user).Association("PermittedHosts").Replace(hosts); assocErr != nil { return assocErr } } return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) return } h.logUserAudit(c, "user_invite", &user, map[string]any{ "target_email": user.Email, "target_role": user.Role, "invite_status": user.InviteStatus, }) // Send invite email asynchronously (non-blocking) // Capture the generated invite URL from configured public URL only. inviteURL := "" baseURL, hasConfiguredPublicURL := utils.GetConfiguredPublicURL(h.DB) if hasConfiguredPublicURL { inviteURL = fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken) } // Only mark as sent when SMTP is configured AND invite URL is usable. emailSent := false if h.MailService.IsConfigured() && hasConfiguredPublicURL { emailSent = true userEmail := user.Email userToken := inviteToken appName := getAppName(h.DB) go func() { if err := h.MailService.SendInvite(userEmail, userToken, appName, baseURL); err != nil { // Log failure but don't block response middleware.GetRequestLogger(c).WithField("user_email", sanitizeForLog(userEmail)).WithField("error", sanitizeForLog(err.Error())).Error("Failed to send invite email") } }() } c.JSON(http.StatusCreated, gin.H{ "id": user.ID, "uuid": user.UUID, "email": user.Email, "role": user.Role, "invite_token_masked": maskSecretForResponse(inviteToken), "invite_url": redactInviteURL(inviteURL), "email_sent": emailSent, "expires_at": inviteExpires, }) } // PreviewInviteURLRequest represents the request for previewing an invite URL. type PreviewInviteURLRequest struct { Email string `json:"email" binding:"required,email"` } // PreviewInviteURL returns what the invite URL would look like with current settings. func (h *UserHandler) PreviewInviteURL(c *gin.Context) { if !requireAdmin(c) { return } var req PreviewInviteURLRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } baseURL, isConfigured := utils.GetConfiguredPublicURL(h.DB) // Generate a sample token for preview (not stored) sampleToken := "SAMPLE_TOKEN_PREVIEW" inviteURL := "" if isConfigured { inviteURL = fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), sampleToken) } warningMessage := "" if !isConfigured { warningMessage = "Application URL not configured. The invite link may not be accessible from external networks." } c.JSON(http.StatusOK, gin.H{ "preview_url": inviteURL, "base_url": baseURL, "is_configured": isConfigured, "email": req.Email, "warning": !isConfigured, "warning_message": warningMessage, }) } // getAppName retrieves the application name from settings or returns a default. func getAppName(db *gorm.DB) string { var setting models.Setting if err := db.Where("key = ?", "app_name").First(&setting).Error; err == nil && setting.Value != "" { return setting.Value } return "Charon" } // GetUser returns a single user by ID (admin only). func (h *UserHandler) GetUser(c *gin.Context) { if !requireAdmin(c) { return } idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) return } var user models.User if err := h.DB.Preload("PermittedHosts").First(&user, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } // Build permitted host IDs list permittedHostIDs := make([]uint, len(user.PermittedHosts)) for i, host := range user.PermittedHosts { permittedHostIDs[i] = host.ID } c.JSON(http.StatusOK, gin.H{ "id": user.ID, "uuid": user.UUID, "email": user.Email, "name": user.Name, "role": user.Role, "enabled": user.Enabled, "last_login": user.LastLogin, "invite_status": user.InviteStatus, "invited_at": user.InvitedAt, "permission_mode": user.PermissionMode, "permitted_hosts": permittedHostIDs, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, }) } // UpdateUserRequest represents the request body for updating a user. type UpdateUserRequest struct { Name string `json:"name"` Email string `json:"email"` Password *string `json:"password" binding:"omitempty,min=8"` Role string `json:"role"` Enabled *bool `json:"enabled"` } // UpdateUser updates an existing user (admin only for management fields, self-service for name/password). func (h *UserHandler) UpdateUser(c *gin.Context) { currentRole := c.GetString("role") currentUserIDRaw, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } currentUserID, ok := currentUserIDRaw.(uint) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"}) return } idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) return } var user models.User if findErr := h.DB.First(&user, id).Error; findErr != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } var req UpdateUserRequest if bindErr := c.ShouldBindJSON(&req); bindErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } isSelf := uint(id) == currentUserID isCallerAdmin := currentRole == string(models.RoleAdmin) // Non-admin users can only update their own name and password via this endpoint. // Email changes require password verification and must go through PUT /user/profile. if !isCallerAdmin { if !isSelf { c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) return } if req.Email != "" { c.JSON(http.StatusForbidden, gin.H{"error": "Email changes must be made via your profile settings"}) return } if req.Role != "" || req.Enabled != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify role or enabled status"}) return } } updates := make(map[string]any) if req.Name != "" { updates["name"] = req.Name } if req.Email != "" { email := strings.ToLower(req.Email) var count int64 if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) return } updates["email"] = email } needsSessionInvalidation := false if req.Role != "" { newRole := models.UserRole(req.Role) if !newRole.IsValid() { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"}) return } if newRole != user.Role { // Self-demotion prevention if isSelf { c.JSON(http.StatusForbidden, gin.H{"error": "Cannot change your own role"}) return } updates["role"] = string(newRole) needsSessionInvalidation = true } } if req.Password != nil { if hashErr := user.SetPassword(*req.Password); hashErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } updates["password_hash"] = user.PasswordHash updates["failed_login_attempts"] = 0 updates["locked_until"] = nil } if req.Enabled != nil && *req.Enabled != user.Enabled { // Prevent self-disable if isSelf && !*req.Enabled { c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable your own account"}) return } updates["enabled"] = *req.Enabled if !*req.Enabled { needsSessionInvalidation = true } } // Wrap the last-admin checks and the actual update in a transaction to prevent // race conditions: two concurrent requests could both read adminCount==2 // and both proceed, leaving zero admins. err = h.DB.Transaction(func(tx *gorm.DB) error { // Re-fetch user inside transaction for consistent state if txErr := tx.First(&user, id).Error; txErr != nil { return txErr } // Last-admin protection for role demotion if newRoleStr, ok := updates["role"]; ok { newRole := models.UserRole(newRoleStr.(string)) if user.Role == models.RoleAdmin && newRole != models.RoleAdmin { var adminCount int64 // Policy: count only enabled admins. This is stricter than "WHERE role = ?" // because a disabled admin cannot act; treating them as non-existent // prevents leaving the system with only disabled admins. tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount) if adminCount <= 1 { return fmt.Errorf("cannot demote the last admin") } } } // Last-admin protection for disabling if enabledVal, ok := updates["enabled"]; ok { if enabled, isBool := enabledVal.(bool); isBool && !enabled { if user.Role == models.RoleAdmin { var adminCount int64 // Policy: count only enabled admins (same rationale as above). tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount) if adminCount <= 1 { return fmt.Errorf("cannot disable the last admin") } } } } if len(updates) > 0 { if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil { return txErr } } return nil }) if err != nil { errMsg := err.Error() if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) return } if len(updates) > 0 { if needsSessionInvalidation && h.AuthService != nil { if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"}) return } } h.logUserAudit(c, "user_update", &user, map[string]any{ "target_email": user.Email, "target_role": user.Role, "fields": mapsKeys(updates), }) } c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"}) } func mapsKeys(values map[string]any) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } return keys } // DeleteUser deletes a user (admin only). func (h *UserHandler) DeleteUser(c *gin.Context) { if !requireAdmin(c) { return } currentUserIDRaw, _ := c.Get("userID") currentUserID, _ := currentUserIDRaw.(uint) idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) return } // Prevent self-deletion if uint(id) == currentUserID { c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"}) return } var user models.User if findErr := h.DB.First(&user, id).Error; findErr != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } // Clear associations first if err := h.DB.Model(&user).Association("PermittedHosts").Clear(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user associations"}) return } if err := h.DB.Delete(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"}) return } h.logUserAudit(c, "user_delete", &user, map[string]any{ "target_email": user.Email, "target_role": user.Role, }) c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) } // UpdateUserPermissionsRequest represents the request body for updating user permissions. type UpdateUserPermissionsRequest struct { PermissionMode string `json:"permission_mode" binding:"required,oneof=allow_all deny_all"` PermittedHosts []uint `json:"permitted_hosts"` } // ResendInvite regenerates and resends an invitation to a pending user (admin only). func (h *UserHandler) ResendInvite(c *gin.Context) { if !requireAdmin(c) { return } idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) return } var user models.User if findErr := h.DB.First(&user, id).Error; findErr != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } // Verify user has a pending invite if user.InviteStatus != "pending" { c.JSON(http.StatusBadRequest, gin.H{"error": "User does not have a pending invite"}) return } // Generate new invite token inviteToken, err := generateSecureToken(32) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"}) return } // Set new invite expiration (48 hours) inviteExpires := time.Now().Add(48 * time.Hour) // Update user with new token if err := h.DB.Model(&user).Updates(map[string]any{ "invite_token": inviteToken, "invite_expires": inviteExpires, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update invite token"}) return } // Try to send invite email emailSent := false if h.MailService.IsConfigured() { baseURL, ok := utils.GetConfiguredPublicURL(h.DB) if ok { appName := getAppName(h.DB) if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil { emailSent = true } } } c.JSON(http.StatusOK, gin.H{ "id": user.ID, "uuid": user.UUID, "email": user.Email, "role": user.Role, "invite_token_masked": maskSecretForResponse(inviteToken), "email_sent": emailSent, "expires_at": inviteExpires, }) } func maskSecretForResponse(value string) string { if strings.TrimSpace(value) == "" { return "" } return "********" } func redactInviteURL(inviteURL string) string { if strings.TrimSpace(inviteURL) == "" { return "" } return "[REDACTED]" } // UpdateUserPermissions updates a user's permission mode and host exceptions (admin only). func (h *UserHandler) UpdateUserPermissions(c *gin.Context) { if !requireAdmin(c) { return } idParam := c.Param("id") id, err := strconv.ParseUint(idParam, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) return } var user models.User if findErr := h.DB.First(&user, id).Error; findErr != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } var req UpdateUserPermissionsRequest if bindErr := c.ShouldBindJSON(&req); bindErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } err = h.DB.Transaction(func(tx *gorm.DB) error { // Update permission mode if txErr := tx.Model(&user).Update("permission_mode", req.PermissionMode).Error; txErr != nil { return txErr } // Update permitted hosts var hosts []models.ProxyHost if len(req.PermittedHosts) > 0 { if findErr := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; findErr != nil { return findErr } } if assocErr := tx.Model(&user).Association("PermittedHosts").Replace(hosts); assocErr != nil { return assocErr } return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update permissions: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Permissions updated successfully"}) } // ValidateInvite validates an invite token (public endpoint). func (h *UserHandler) ValidateInvite(c *gin.Context) { token := c.Query("token") if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Token required"}) return } var user models.User if err := h.DB.Where("invite_token = ?", token).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"}) return } // Check if token is expired if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) { c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"}) return } // Check if already accepted if user.InviteStatus != "pending" { c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"}) return } c.JSON(http.StatusOK, gin.H{ "valid": true, "email": user.Email, }) } // AcceptInviteRequest represents the request body for accepting an invite. type AcceptInviteRequest struct { Token string `json:"token" binding:"required"` Name string `json:"name" binding:"required"` Password string `json:"password" binding:"required,min=8"` } // AcceptInvite accepts an invitation and sets the user's password (public endpoint). func (h *UserHandler) AcceptInvite(c *gin.Context) { var req AcceptInviteRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var user models.User if err := h.DB.Where("invite_token = ?", req.Token).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"}) return } // Check if token is expired if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) { // Mark as expired h.DB.Model(&user).Update("invite_status", "expired") c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"}) return } // Check if already accepted if user.InviteStatus != "pending" { c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"}) return } // Set password and activate user if err := user.SetPassword(req.Password); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set password"}) return } if err := h.DB.Model(&user).Updates(map[string]any{ "name": req.Name, "password_hash": user.PasswordHash, "enabled": true, "invite_token": "", // Clear token "invite_expires": nil, // Clear expiration "invite_status": "accepted", }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept invite"}) return } h.logUserAudit(c, "user_invite_accept", &user, map[string]any{ "target_email": user.Email, "invite_status": "accepted", }) c.JSON(http.StatusOK, gin.H{ "message": "Invite accepted successfully", "email": user.Email, }) }