diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index f8e40b0d..025dde67 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -131,7 +131,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService) - controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService) controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 069b814c..97c892a9 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -13,25 +13,25 @@ import ( ) type services struct { - appConfigService *service.AppConfigService - appImagesService *service.AppImagesService - emailService *service.EmailService - geoLiteService *service.GeoLiteService - auditLogService *service.AuditLogService - jwtService *service.JwtService - webauthnService *service.WebAuthnService - scimService *service.ScimService - userService *service.UserService - customClaimService *service.CustomClaimService - oidcService *service.OidcService - userGroupService *service.UserGroupService - ldapService *service.LdapService - apiKeyService *service.ApiKeyService - versionService *service.VersionService - fileStorage storage.FileStorage - appLockService *service.AppLockService - userSignUpService *service.UserSignUpService - oneTimeAccessService *service.OneTimeAccessService + appConfigService *service.AppConfigService + appImagesService *service.AppImagesService + emailService *service.EmailService + geoLiteService *service.GeoLiteService + auditLogService *service.AuditLogService + jwtService *service.JwtService + webauthnService *service.WebAuthnService + scimService *service.ScimService + userService *service.UserService + customFieldValueService *service.CustomFieldValueService + oidcService *service.OidcService + userGroupService *service.UserGroupService + ldapService *service.LdapService + apiKeyService *service.ApiKeyService + versionService *service.VersionService + fileStorage storage.FileStorage + appLockService *service.AppLockService + userSignUpService *service.UserSignUpService + oneTimeAccessService *service.OneTimeAccessService } // Initializes all services @@ -59,7 +59,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima return nil, fmt.Errorf("failed to create JWT service: %w", err) } - svc.customClaimService = service.NewCustomClaimService(db) + svc.customFieldValueService = service.NewCustomFieldValueService(db, svc.appConfigService) svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService) if err != nil { return nil, fmt.Errorf("failed to create WebAuthn service: %w", err) @@ -67,13 +67,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima svc.scimService = service.NewScimService(db, scheduler, httpClient) - svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage) + svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customFieldValueService, svc.webauthnService, svc.scimService, httpClient, fileStorage) if err != nil { return nil, fmt.Errorf("failed to create OIDC service: %w", err) } - svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService) - svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) + svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.customFieldValueService, svc.scimService) + svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customFieldValueService, svc.appImagesService, svc.scimService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 3bb802b5..941bf305 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -171,23 +171,30 @@ type MissingSessionIdError struct{} func (e MissingSessionIdError) Error() string { return "Missing session id" } func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest } -type ReservedClaimError struct { +type ReservedCustomFieldError struct { Key string } -func (e ReservedClaimError) Error() string { - return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key) +func (e ReservedCustomFieldError) Error() string { + return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key) } -func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest } +func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest } -type DuplicateClaimError struct { +type DuplicateCustomFieldError struct { Key string } -func (e DuplicateClaimError) Error() string { - return fmt.Sprintf("Claim %s is already defined", e.Key) +func (e DuplicateCustomFieldError) Error() string { + return fmt.Sprintf("Custom field %s is already defined", e.Key) } -func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest } +func (e DuplicateCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest } + +type CustomFieldValidationError struct { + Message string +} + +func (e CustomFieldValidationError) Error() string { return e.Message } +func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest } type OidcInvalidCodeVerifierError struct{} diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go deleted file mode 100644 index 8b4731c3..00000000 --- a/backend/internal/controller/custom_claim_controller.go +++ /dev/null @@ -1,115 +0,0 @@ -package controller - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/middleware" - "github.com/pocket-id/pocket-id/backend/internal/service" -) - -// NewCustomClaimController creates a new controller for custom claim management -// @Summary Custom claim management controller -// @Description Initializes all custom claim-related API endpoints -// @Tags Custom Claims -func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) { - wkc := &CustomClaimController{customClaimService: customClaimService} - - customClaimsGroup := group.Group("/custom-claims") - customClaimsGroup.Use(authMiddleware.Add()) - { - customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler) - customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler) - customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler) - } -} - -type CustomClaimController struct { - customClaimService *service.CustomClaimService -} - -// getSuggestionsHandler godoc -// @Summary Get custom claim suggestions -// @Description Get a list of suggested custom claim names -// @Tags Custom Claims -// @Produce json -// @Success 200 {array} string "List of suggested custom claim names" -// @Router /api/custom-claims/suggestions [get] -func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { - claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context()) - if err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, claims) -} - -// UpdateCustomClaimsForUserHandler godoc -// @Summary Update custom claims for a user -// @Description Update or create custom claims for a specific user -// @Tags Custom Claims -// @Accept json -// @Produce json -// @Param userId path string true "User ID" -// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user" -// @Success 200 {array} dto.CustomClaimDto "Updated custom claims" -// @Router /api/custom-claims/user/{userId} [put] -func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) { - var input []dto.CustomClaimCreateDto - - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userId := c.Param("userId") - claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input) - if err != nil { - _ = c.Error(err) - return - } - - var customClaimsDto []dto.CustomClaimDto - if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, customClaimsDto) -} - -// UpdateCustomClaimsForUserGroupHandler godoc -// @Summary Update custom claims for a user group -// @Description Update or create custom claims for a specific user group -// @Tags Custom Claims -// @Accept json -// @Produce json -// @Param userGroupId path string true "User Group ID" -// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group" -// @Success 200 {array} dto.CustomClaimDto "Updated custom claims" -// @Router /api/custom-claims/user-group/{userGroupId} [put] -func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { - var input []dto.CustomClaimCreateDto - - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userGroupId := c.Param("userGroupId") - claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input) - if err != nil { - _ = c.Error(err) - return - } - - var customClaimsDto []dto.CustomClaimDto - if err := dto.MapStructList(claims, &customClaimsDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, customClaimsDto) -} diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 7fbabb9a..c5ba6918 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -20,7 +20,7 @@ type AppConfigUpdateDto struct { AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"` SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"` - SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"` + CustomFields string `json:"customFields" binding:"omitempty,json"` AccentColor string `json:"accentColor"` RequireUserEmail string `json:"requireUserEmail" binding:"required"` SmtpHost string `json:"smtpHost"` diff --git a/backend/internal/dto/custom_claim_dto.go b/backend/internal/dto/custom_claim_dto.go deleted file mode 100644 index 9378f74c..00000000 --- a/backend/internal/dto/custom_claim_dto.go +++ /dev/null @@ -1,11 +0,0 @@ -package dto - -type CustomClaimDto struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type CustomClaimCreateDto struct { - Key string `json:"key" binding:"required" unorm:"nfc"` - Value string `json:"value" binding:"required" unorm:"nfc"` -} diff --git a/backend/internal/dto/custom_field_dto.go b/backend/internal/dto/custom_field_dto.go new file mode 100644 index 00000000..acfc2c1d --- /dev/null +++ b/backend/internal/dto/custom_field_dto.go @@ -0,0 +1,42 @@ +package dto + +type CustomFieldValueDto struct { + CustomFieldID string `json:"customFieldId"` + Key string `json:"key,omitempty"` + Value string `json:"value"` +} + +type CustomFieldValueCreateDto struct { + CustomFieldID string `json:"customFieldId" binding:"required_without=Key" unorm:"nfc"` + Key string `json:"key,omitempty" unorm:"nfc"` + Value string `json:"value" unorm:"nfc"` +} + +type CustomFieldType string + +const ( + CustomFieldTypeString CustomFieldType = "string" + CustomFieldTypeNumber CustomFieldType = "number" + CustomFieldTypeBoolean CustomFieldType = "boolean" +) + +type CustomFieldTarget string + +const ( + CustomFieldTargetUser CustomFieldTarget = "user" + CustomFieldTargetGroup CustomFieldTarget = "group" + CustomFieldTargetBoth CustomFieldTarget = "both" +) + +type CustomFieldDto struct { + ID string `json:"id" binding:"required,uuid"` + Key string `json:"key" binding:"required" unorm:"nfc"` + DisplayName string `json:"displayName" binding:"required" unorm:"nfc"` + Type CustomFieldType `json:"type" binding:"required,oneof=string number boolean"` + Target CustomFieldTarget `json:"target" binding:"required,oneof=user group both"` + Required bool `json:"required"` + UserEditable bool `json:"userEditable"` + DefaultValue string `json:"defaultValue" unorm:"nfc"` + ValidationRegex string `json:"validationRegex" binding:"regex" unorm:"nfc"` + ValidationErrorMessage string `json:"validationErrorMessage" unorm:"nfc"` +} diff --git a/backend/internal/dto/signup_dto.go b/backend/internal/dto/signup_dto.go index a31d3e93..8c1c811a 100644 --- a/backend/internal/dto/signup_dto.go +++ b/backend/internal/dto/signup_dto.go @@ -1,9 +1,10 @@ package dto type SignUpDto struct { - Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - Token string `json:"token"` + Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + Token string `json:"token"` + CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index e337528f..86dab134 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -7,33 +7,34 @@ import ( ) type UserDto struct { - ID string `json:"id"` - Username string `json:"username"` - Email *string `json:"email"` - EmailVerified bool `json:"emailVerified"` - FirstName string `json:"firstName"` - LastName *string `json:"lastName"` - DisplayName string `json:"displayName"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - CustomClaims []CustomClaimDto `json:"customClaims"` - UserGroups []UserGroupMinimalDto `json:"userGroups"` - LdapID *string `json:"ldapId"` - Disabled bool `json:"disabled"` + ID string `json:"id"` + Username string `json:"username"` + Email *string `json:"email"` + EmailVerified bool `json:"emailVerified"` + FirstName string `json:"firstName"` + LastName *string `json:"lastName"` + DisplayName string `json:"displayName"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"` + UserGroups []UserGroupMinimalDto `json:"userGroups"` + LdapID *string `json:"ldapId"` + Disabled bool `json:"disabled"` } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - EmailVerified bool `json:"emailVerified"` - FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - Disabled bool `json:"disabled"` - UserGroupIds []string `json:"userGroupIds"` - LdapID string `json:"-"` + Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + EmailVerified bool `json:"emailVerified"` + FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + Disabled bool `json:"disabled"` + UserGroupIds []string `json:"userGroupIds"` + CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"` + LdapID string `json:"-"` } func (u UserCreateDto) Validate() error { diff --git a/backend/internal/dto/user_group_dto.go b/backend/internal/dto/user_group_dto.go index 79cd8d48..d9da7e7b 100644 --- a/backend/internal/dto/user_group_dto.go +++ b/backend/internal/dto/user_group_dto.go @@ -11,7 +11,7 @@ type UserGroupDto struct { ID string `json:"id"` FriendlyName string `json:"friendlyName"` Name string `json:"name"` - CustomClaims []CustomClaimDto `json:"customClaims"` + CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"` LdapID *string `json:"ldapId"` CreatedAt datatype.DateTime `json:"createdAt"` Users []UserDto `json:"users"` @@ -22,7 +22,6 @@ type UserGroupMinimalDto struct { ID string `json:"id"` FriendlyName string `json:"friendlyName"` Name string `json:"name"` - CustomClaims []CustomClaimDto `json:"customClaims"` UserCount int64 `json:"userCount"` LdapID *string `json:"ldapId"` CreatedAt datatype.DateTime `json:"createdAt"` @@ -33,9 +32,10 @@ type UserGroupUpdateAllowedOidcClientsDto struct { } type UserGroupCreateDto struct { - FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` - Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` - LdapID string `json:"-"` + FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` + Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` + CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"` + LdapID string `json:"-"` } func (g UserGroupCreateDto) Validate() error { diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 2380775c..7b514cd4 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -1,11 +1,13 @@ package dto import ( + "errors" "net/url" "regexp" "strings" "time" + "github.com/google/uuid" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/gin-gonic/gin/binding" @@ -33,6 +35,12 @@ func init() { "client_id": func(fl validator.FieldLevel) bool { return ValidateClientID(fl.Field().String()) }, + "regex": func(fl validator.FieldLevel) bool { + return ValidateRegex(fl.Field().String()) + }, + "uuid": func(fl validator.FieldLevel) bool { + return ValidateUUID(fl.Field().String()) + }, "ttl": func(fl validator.FieldLevel) bool { ttl, ok := fl.Field().Interface().(utils.JSONDuration) if !ok { @@ -59,6 +67,16 @@ func init() { } } +func ValidateStruct(input any) error { + e, ok := binding.Validator.Engine().(interface { + Struct(any) error + }) + if !ok { + return errors.New("validator does not implement the expected interface") + } + return e.Struct(input) +} + // ValidateUsername validates username inputs func ValidateUsername(username string) bool { return validateUsernameRegex.MatchString(username) @@ -69,6 +87,20 @@ func ValidateClientID(clientID string) bool { return validateClientIDRegex.MatchString(clientID) } +// ValidateRegex validates that the input is either empty or a compilable regular expression. +func ValidateRegex(value string) bool { + if value == "" { + return true + } + _, err := regexp.Compile(value) + return err == nil +} + +// ValidateUUID validates UUID inputs. +func ValidateUUID(value string) bool { + return uuid.Validate(value) == nil +} + // ValidateCallbackURL validates the input callback URL func ValidateCallbackURL(str string) bool { // Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:" diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index 5f9d595d..da7e2cc4 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -58,6 +58,42 @@ func TestValidateClientID(t *testing.T) { } } +func TestValidateRegex(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"empty", "", true}, + {"valid", "^EMP-[0-9]+$", true}, + {"invalid", "[", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ValidateRegex(tt.input)) + }) + } +} + +func TestValidateUUID(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid", "89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03", true}, + {"invalid", "field-1", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ValidateUUID(tt.input)) + }) + } +} + func TestValidateResponseMode(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 60dae01e..6b64eb28 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -37,7 +37,9 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { jwtService, err := service.NewJwtService(t.Context(), db, appConfigService) require.NoError(t, err) - userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil) + customFieldsValueService := service.NewCustomFieldValueService(db, appConfigService) + + userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, customFieldsValueService, nil, nil, nil) apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil) require.NoError(t, err) diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 3e008478..a29b88c7 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -43,7 +43,7 @@ type AppConfig struct { AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"` - SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"` + CustomFields AppConfigVariable `key:"customFields,public"` // Public // Internal InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal // Email diff --git a/backend/internal/model/custom_claim.go b/backend/internal/model/custom_claim.go deleted file mode 100644 index c47f934c..00000000 --- a/backend/internal/model/custom_claim.go +++ /dev/null @@ -1,11 +0,0 @@ -package model - -type CustomClaim struct { - Base - - Key string - Value string - - UserID *string - UserGroupID *string -} diff --git a/backend/internal/model/custom_field_value.go b/backend/internal/model/custom_field_value.go new file mode 100644 index 00000000..7c1f4a2a --- /dev/null +++ b/backend/internal/model/custom_field_value.go @@ -0,0 +1,11 @@ +package model + +type CustomFieldValue struct { + Base + + CustomFieldID string + Value string + + UserID *string + UserGroupID *string +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index bfd90cc2..25cfc222 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -26,9 +26,9 @@ type User struct { Disabled bool `sortable:"true" filterable:"true"` UpdatedAt *datatype.DateTime - CustomClaims []CustomClaim - UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` - Credentials []WebauthnCredential + CustomFieldValues []CustomFieldValue + UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` + Credentials []WebauthnCredential } func (u User) WebAuthnID() []byte { return []byte(u.ID) } diff --git a/backend/internal/model/user_group.go b/backend/internal/model/user_group.go index 85d9c150..af0ca10a 100644 --- a/backend/internal/model/user_group.go +++ b/backend/internal/model/user_group.go @@ -13,7 +13,7 @@ type UserGroup struct { LdapID *string UpdatedAt *datatype.DateTime Users []User `gorm:"many2many:user_groups_users;"` - CustomClaims []CustomClaim + CustomFieldValues []CustomFieldValue AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"` } diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index be1865db..56835330 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -67,7 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"}, - SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, + CustomFields: model.AppConfigVariable{Value: "[]"}, AccentColor: model.AppConfigVariable{Value: "default"}, // Internal InstanceID: model.AppConfigVariable{Value: ""}, @@ -158,6 +159,87 @@ func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx return nil } +func (s *AppConfigService) normalizeCustomFieldConfigUpdate(ctx context.Context, tx *gorm.DB, oldValue, newValue string) (string, error) { + if newValue == "" { + return "", nil + } + + oldFields, err := ParseCustomFieldDefinitions(oldValue) + if err != nil { + return "", err + } + newFields, err := ParseCustomFieldDefinitions(newValue) + if err != nil { + return "", err + } + + oldFieldsByID := make(map[string]dto.CustomFieldDto, len(oldFields)) + for _, oldField := range oldFields { + oldFieldsByID[oldField.ID] = oldField + } + + newFieldsByID := make(map[string]dto.CustomFieldDto, len(newFields)) + for _, newField := range newFields { + newFieldsByID[newField.ID] = newField + oldField, ok := oldFieldsByID[newField.ID] + if !ok { + continue + } + + if oldField.Type != newField.Type { + return "", &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s type can't be changed", oldField.Key)} + } + + // If the field existed before, but the appliesTo was changed so that it no longer applies to users/groups, + // then we need to delete the corresponding values on users/groups + for _, idType := range []idType{UserID, UserGroupID} { + oldApplies := customFieldAppliesTo(oldField, idType) + newApplies := customFieldAppliesTo(newField, idType) + if oldApplies && !newApplies { + if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil { + return "", err + } + } + } + } + + // Check if any fields were removed, and if so delete the corresponding values on users/groups + for _, oldField := range oldFields { + if _, ok := newFieldsByID[oldField.ID]; ok { + continue + } + for _, idType := range []idType{UserID, UserGroupID} { + if !customFieldAppliesTo(oldField, idType) { + continue + } + if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil { + return "", err + } + } + } + + normalizedValue, err := json.Marshal(newFields) + if err != nil { + return "", fmt.Errorf("failed to normalize custom fields JSON: %w", err) + } + return string(normalizedValue), nil +} + +func (s *AppConfigService) deleteCustomFieldValuesForFieldID(ctx context.Context, tx *gorm.DB, idType idType, customFieldID string) error { + query := tx.WithContext(ctx).Model(&model.CustomFieldValue{}) + switch idType { + case UserID: + query = query.Where("user_id IS NOT NULL") + case UserGroupID: + query = query.Where("user_group_id IS NOT NULL") + } + + if err := query.Where("custom_field_id = ?", customFieldID).Delete(&model.CustomFieldValue{}).Error; err != nil { + return fmt.Errorf("failed to delete custom field values for field %s: %w", customFieldID, err) + } + return nil +} + func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { if common.EnvConfig.UiConfigDisabled { return nil, &common.UiConfigDisabledError{} @@ -179,6 +261,11 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon return nil, fmt.Errorf("failed to reload config from database: %w", err) } + input.CustomFields, err = s.normalizeCustomFieldConfigUpdate(ctx, tx, cfg.CustomFields.Value, input.CustomFields) + if err != nil { + return nil, err + } + defaultCfg := s.getDefaultDbConfig() // Iterate through all the fields to update diff --git a/backend/internal/service/app_config_service_test.go b/backend/internal/service/app_config_service_test.go index f22684fc..04a409fd 100644 --- a/backend/internal/service/app_config_service_test.go +++ b/backend/internal/service/app_config_service_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "sync/atomic" "testing" @@ -243,7 +244,7 @@ func TestUpdateAppConfigValues(t *testing.T) { // Verify database was updated var count int64 db.Model(&model.AppConfigVariable{}).Count(&count) - require.Equal(t, int64(3), count) + require.GreaterOrEqual(t, count, int64(3)) var appName, sessionDuration, smtpHost model.AppConfigVariable err = db.Where("key = ?", "appName").First(&appName).Error @@ -470,4 +471,98 @@ func TestUpdateAppConfig(t *testing.T) { var uiConfigDisabledErr *common.UiConfigDisabledError require.ErrorAs(t, err, &uiConfigDisabledErr) }) + + t.Run("keeps custom field values when custom field key changes", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fieldID := "d20db690-c6fb-4b5b-8288-ac68eb80c6f4" + oldCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"department","displayName":"Department","type":"string","target":"user","required":false}]` + err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error + require.NoError(t, err) + + user := model.User{Username: "test-user"} + err = db.Create(&user).Error + require.NoError(t, err) + err = db.Create(&model.CustomFieldValue{ + CustomFieldID: fieldID, + Value: "Engineering", + UserID: &user.ID, + }).Error + require.NoError(t, err) + + service := &AppConfigService{db: db} + err = service.LoadDbConfig(t.Context()) + require.NoError(t, err) + + newCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"team","displayName":"Team","type":"string","target":"user","required":false}]` + _, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ + CustomFields: newCustomFields, + }) + require.NoError(t, err) + + var customFieldValue model.CustomFieldValue + err = db.Where("user_id = ?", user.ID).First(&customFieldValue).Error + require.NoError(t, err) + require.Equal(t, fieldID, customFieldValue.CustomFieldID) + require.Equal(t, "Engineering", customFieldValue.Value) + + var storedConfig model.AppConfigVariable + err = db.Where("key = ?", "customFields").First(&storedConfig).Error + require.NoError(t, err) + var storedFields []dto.CustomFieldDto + err = json.Unmarshal([]byte(storedConfig.Value), &storedFields) + require.NoError(t, err) + require.Len(t, storedFields, 1) + require.Equal(t, fieldID, storedFields[0].ID) + require.Equal(t, "team", storedFields[0].Key) + }) + + t.Run("rejects custom field type changes", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + oldCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"string","target":"user","required":false}]` + err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error + require.NoError(t, err) + + service := &AppConfigService{db: db} + err = service.LoadDbConfig(t.Context()) + require.NoError(t, err) + + newCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"number","target":"user","required":false}]` + _, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ + CustomFields: newCustomFields, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "type can't be changed") + }) + + t.Run("deletes custom field values when custom field is removed", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + fieldID := "a99f05e6-57a0-468d-a8fe-98bd637cbf98" + oldCustomFields := `[{"id":"a99f05e6-57a0-468d-a8fe-98bd637cbf98","key":"department","displayName":"Department","type":"string","target":"user","required":false}]` + err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error + require.NoError(t, err) + + user := model.User{Username: "test-user"} + err = db.Create(&user).Error + require.NoError(t, err) + err = db.Create(&model.CustomFieldValue{ + CustomFieldID: fieldID, + Value: "Engineering", + UserID: &user.ID, + }).Error + require.NoError(t, err) + + service := &AppConfigService{db: db} + err = service.LoadDbConfig(t.Context()) + require.NoError(t, err) + + _, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ + CustomFields: "[]", + }) + require.NoError(t, err) + + var count int64 + err = db.Model(&model.CustomFieldValue{}).Where("user_id = ?", user.ID).Count(&count).Error + require.NoError(t, err) + require.Zero(t, count) + }) } diff --git a/backend/internal/service/custom_claim_service.go b/backend/internal/service/custom_claim_service.go deleted file mode 100644 index 41cf1e6c..00000000 --- a/backend/internal/service/custom_claim_service.go +++ /dev/null @@ -1,261 +0,0 @@ -package service - -import ( - "context" - - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" - "gorm.io/gorm" -) - -type CustomClaimService struct { - db *gorm.DB -} - -func NewCustomClaimService(db *gorm.DB) *CustomClaimService { - return &CustomClaimService{db: db} -} - -// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username -func isReservedClaim(key string) bool { - switch key { - case "given_name", - "family_name", - "name", - "email", - "email_verified", - "preferred_username", - "display_name", - "groups", - TokenTypeClaim, - "sub", - "iss", - "aud", - "exp", - "iat", - "auth_time", - "nonce", - "acr", - "amr", - "azp", - "nbf", - "jti": - return true - default: - return false - } -} - -// idType is the type of the id used to identify the user or user group -type idType string - -const ( - UserID idType = "user_id" - UserGroupID idType = "user_group_id" -) - -// UpdateCustomClaimsForUser updates the custom claims for a user -func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx) - if err != nil { - return nil, err - } - - err = tx.Commit().Error - if err != nil { - return nil, err - } - - return updatedClaims, nil -} - -// UpdateCustomClaimsForUserGroup updates the custom claims for a user group -func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx) - if err != nil { - return nil, err - } - - err = tx.Commit().Error - if err != nil { - return nil, err - } - - return updatedClaims, nil -} - -// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction -func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) { - // Check for duplicate keys in the claims slice - seenKeys := make(map[string]struct{}) - for _, claim := range claims { - if _, ok := seenKeys[claim.Key]; ok { - return nil, &common.DuplicateClaimError{Key: claim.Key} - } - seenKeys[claim.Key] = struct{}{} - } - - var existingClaims []model.CustomClaim - err := tx. - WithContext(ctx). - Where(string(idType), value). - Find(&existingClaims). - Error - if err != nil { - return nil, err - } - - // Delete claims that are not in the new list - for _, existingClaim := range existingClaims { - found := false - for _, claim := range claims { - if claim.Key == existingClaim.Key { - found = true - break - } - } - - if !found { - err = tx. - WithContext(ctx). - Delete(&existingClaim). - Error - if err != nil { - return nil, err - } - } - } - - // Add or update claims - for _, claim := range claims { - if isReservedClaim(claim.Key) { - return nil, &common.ReservedClaimError{Key: claim.Key} - } - customClaim := model.CustomClaim{ - Key: claim.Key, - Value: claim.Value, - } - - switch idType { - case UserID: - customClaim.UserID = &value - case UserGroupID: - customClaim.UserGroupID = &value - } - - // Update the claim if it already exists or create a new one - err = tx. - WithContext(ctx). - Where(string(idType)+" = ? AND key = ?", value, claim.Key). - Assign(&customClaim). - FirstOrCreate(&model.CustomClaim{}). - Error - if err != nil { - return nil, err - } - } - - // Get the updated claims - var updatedClaims []model.CustomClaim - err = tx. - WithContext(ctx). - Where(string(idType)+" = ?", value). - Find(&updatedClaims). - Error - if err != nil { - return nil, err - } - - return updatedClaims, nil -} - -func (s *CustomClaimService) GetCustomClaimsForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) { - var customClaims []model.CustomClaim - err := tx. - WithContext(ctx). - Where("user_id = ?", userID). - Find(&customClaims). - Error - return customClaims, err -} - -func (s *CustomClaimService) GetCustomClaimsForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomClaim, error) { - var customClaims []model.CustomClaim - err := tx. - WithContext(ctx). - Where("user_group_id = ?", userGroupID). - Find(&customClaims). - Error - return customClaims, err -} - -// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of, -// prioritizing the user's claims over user group claims with the same key. -func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) { - // Get the custom claims of the user - customClaims, err := s.GetCustomClaimsForUser(ctx, userID, tx) - if err != nil { - return nil, err - } - - // Store user's claims in a map to prioritize and prevent duplicates - claimsMap := make(map[string]model.CustomClaim) - for _, claim := range customClaims { - claimsMap[claim.Key] = claim - } - - // Get all user groups of the user - var userGroupsOfUser []model.UserGroup - err = tx. - WithContext(ctx). - Preload("CustomClaims"). - Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id"). - Where("user_groups_users.user_id = ?", userID). - Find(&userGroupsOfUser).Error - if err != nil { - return nil, err - } - - // Add only non-duplicate custom claims from user groups - for _, userGroup := range userGroupsOfUser { - for _, groupClaim := range userGroup.CustomClaims { - // Only add claim if it does not exist in the user's claims - if _, exists := claimsMap[groupClaim.Key]; !exists { - claimsMap[groupClaim.Key] = groupClaim - } - } - } - - // Convert the claimsMap back to a slice - finalClaims := make([]model.CustomClaim, 0, len(claimsMap)) - for _, claim := range claimsMap { - finalClaims = append(finalClaims, claim) - } - - return finalClaims, nil -} - -// GetSuggestions returns a list of custom claim keys that have been used before -func (s *CustomClaimService) GetSuggestions(ctx context.Context) ([]string, error) { - var customClaimsKeys []string - - err := s.db. - WithContext(ctx). - Model(&model.CustomClaim{}). - Group("key"). - Order("COUNT(*) DESC"). - Pluck("key", &customClaimsKeys).Error - - return customClaimsKeys, err -} diff --git a/backend/internal/service/custom_field_service.go b/backend/internal/service/custom_field_service.go new file mode 100644 index 00000000..ca111315 --- /dev/null +++ b/backend/internal/service/custom_field_service.go @@ -0,0 +1,566 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" +) + +type CustomFieldValueService struct { + db *gorm.DB + appConfigService *AppConfigService +} + +func NewCustomFieldValueService(db *gorm.DB, appConfigService *AppConfigService) *CustomFieldValueService { + return &CustomFieldValueService{db: db, appConfigService: appConfigService} +} + +func customFieldAppliesTo(field dto.CustomFieldDto, idType idType) bool { + switch field.Target { + case dto.CustomFieldTargetBoth: + return true + case dto.CustomFieldTargetUser: + return idType == UserID + case dto.CustomFieldTargetGroup: + return idType == UserGroupID + default: + return false + } +} + +// isReservedOIDCClaim checks if a key is reserved by standard OIDC claims, e.g. email or preferred_username. +func isReservedOIDCClaim(key string) bool { + switch key { + case "given_name", + "family_name", + "name", + "email", + "email_verified", + "preferred_username", + "display_name", + "groups", + TokenTypeClaim, + "sub", + "iss", + "aud", + "exp", + "iat", + "auth_time", + "nonce", + "acr", + "amr", + "azp", + "nbf", + "jti": + return true + default: + return false + } +} + +// idType is the type of the id used to identify the user or user group +type idType string + +const ( + UserID idType = "user_id" + UserGroupID idType = "user_group_id" +) + +// UpdateCustomFieldValuesForUser updates the custom field values for a user. +func (s *CustomFieldValueService) UpdateCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserID, userID, customFieldValues, tx) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return updatedCustomFieldValues, nil +} + +// updateSelfEditableCustomFieldValuesForUser updates only the custom fields a user is allowed to edit themselves. +func (s *CustomFieldValueService) updateSelfEditableCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) { + fields, err := s.GetConfiguredCustomFieldsForTarget(UserID) + if err != nil { + return nil, err + } + + editableFields := make([]dto.CustomFieldDto, 0, len(fields)) + for _, field := range fields { + if !field.UserEditable { + continue + } + editableFields = append(editableFields, field) + } + + return s.updateCustomFieldValuesForFields(ctx, UserID, userID, customFieldValues, editableFields, tx) +} + +func (s *CustomFieldValueService) updateCustomFieldValuesForFields(ctx context.Context, idType idType, ownerID string, customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto, tx *gorm.DB) ([]model.CustomFieldValue, error) { + normalizedCustomFieldValues, err := validateCustomFieldValuesAgainstFields(customFieldValues, fields) + if err != nil { + return nil, err + } + + fieldIDs := make([]string, 0, len(fields)) + for _, field := range fields { + fieldIDs = append(fieldIDs, field.ID) + } + + valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(normalizedCustomFieldValues)) + fieldIDsToKeep := make([]string, 0, len(normalizedCustomFieldValues)) + for _, customFieldValue := range normalizedCustomFieldValues { + valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue + fieldIDsToKeep = append(fieldIDsToKeep, customFieldValue.CustomFieldID) + } + + if len(fieldIDs) > 0 { + deleteQuery := tx.WithContext(ctx). + Where(string(idType)+" = ? AND custom_field_id IN ?", ownerID, fieldIDs) + if len(fieldIDsToKeep) > 0 { + deleteQuery = deleteQuery.Where("custom_field_id NOT IN ?", fieldIDsToKeep) + } + if err := deleteQuery.Delete(&model.CustomFieldValue{}).Error; err != nil { + return nil, err + } + } + + for _, customFieldValue := range valuesByFieldID { + value := model.CustomFieldValue{ + CustomFieldID: customFieldValue.CustomFieldID, + Value: customFieldValue.Value, + } + switch idType { + case UserID: + value.UserID = &ownerID + case UserGroupID: + value.UserGroupID = &ownerID + } + if err := tx. + WithContext(ctx). + Where(string(idType)+" = ? AND custom_field_id = ?", ownerID, customFieldValue.CustomFieldID). + Assign(&value). + FirstOrCreate(&model.CustomFieldValue{}). + Error; err != nil { + return nil, err + } + } + + switch idType { + case UserID: + return s.GetCustomFieldValuesForUser(ctx, ownerID, tx) + case UserGroupID: + return s.GetCustomFieldValuesForUserGroup(ctx, ownerID, tx) + default: + return nil, nil + } +} + +// UpdateCustomFieldValuesForUserGroup updates the custom field values for a user group. +func (s *CustomFieldValueService) UpdateCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserGroupID, userGroupID, customFieldValues, tx) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return updatedCustomFieldValues, nil +} + +// updateCustomFieldValuesInternal updates the custom field values for a user or user group within a transaction. +func (s *CustomFieldValueService) updateCustomFieldValuesInternal(ctx context.Context, idType idType, value string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) { + fields, err := s.GetConfiguredCustomFieldsForTarget(idType) + if err != nil { + return nil, err + } + + customFieldValues, err = validateCustomFieldValuesAgainstFields(customFieldValues, fields) + if err != nil { + return nil, err + } + + var existingCustomFieldValues []model.CustomFieldValue + err = tx. + WithContext(ctx). + Where(string(idType), value). + Find(&existingCustomFieldValues). + Error + if err != nil { + return nil, err + } + + // Delete values that are not in the new list. + for _, existingCustomFieldValue := range existingCustomFieldValues { + found := false + for _, customFieldValue := range customFieldValues { + if customFieldValue.CustomFieldID == existingCustomFieldValue.CustomFieldID { + found = true + break + } + } + + if !found { + err = tx. + WithContext(ctx). + Delete(&existingCustomFieldValue). + Error + if err != nil { + return nil, err + } + } + } + + // Add or update custom field values. + for _, inputCustomFieldValue := range customFieldValues { + customFieldValue := model.CustomFieldValue{ + CustomFieldID: inputCustomFieldValue.CustomFieldID, + Value: inputCustomFieldValue.Value, + } + + switch idType { + case UserID: + customFieldValue.UserID = &value + case UserGroupID: + customFieldValue.UserGroupID = &value + } + + // Update the value if it already exists or create a new one. + err = tx. + WithContext(ctx). + Where(string(idType)+" = ? AND custom_field_id = ?", value, inputCustomFieldValue.CustomFieldID). + Assign(&customFieldValue). + FirstOrCreate(&model.CustomFieldValue{}). + Error + if err != nil { + return nil, err + } + } + + // Get the updated custom field values. + var updatedCustomFieldValues []model.CustomFieldValue + err = tx. + WithContext(ctx). + Where(string(idType)+" = ?", value). + Find(&updatedCustomFieldValues). + Error + if err != nil { + return nil, err + } + + return updatedCustomFieldValues, nil +} + +func (s *CustomFieldValueService) GetCustomFieldValuesForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) { + var customFieldValues []model.CustomFieldValue + err := tx. + WithContext(ctx). + Where("user_id = ?", userID). + Find(&customFieldValues). + Error + if err != nil { + return nil, err + } + return s.applyDefaultCustomFieldValues(UserID, userID, customFieldValues) +} + +func (s *CustomFieldValueService) GetCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomFieldValue, error) { + var customFieldValues []model.CustomFieldValue + err := tx. + WithContext(ctx). + Where("user_group_id = ?", userGroupID). + Find(&customFieldValues). + Error + if err != nil { + return nil, err + } + return s.applyDefaultCustomFieldValues(UserGroupID, userGroupID, customFieldValues) +} + +// GetCustomFieldValuesForUserWithUserGroups returns the custom field values of a user and all user groups the user is a member of, +// prioritizing the user's values over user group values for the same custom field. +func (s *CustomFieldValueService) GetCustomFieldValuesForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) { + customFieldValues, err := s.GetCustomFieldValuesForUser(ctx, userID, tx) + if err != nil { + return nil, err + } + + valuesByFieldID := make(map[string]model.CustomFieldValue) + for _, customFieldValue := range customFieldValues { + valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue + } + + // Get all user groups of the user + var userGroupsOfUser []model.UserGroup + err = tx. + WithContext(ctx). + Preload("CustomFieldValues"). + Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id"). + Where("user_groups_users.user_id = ?", userID). + Find(&userGroupsOfUser).Error + if err != nil { + return nil, err + } + + // Add only non-duplicate custom fields from user groups + for _, userGroup := range userGroupsOfUser { + groupCustomFieldValues, err := s.applyDefaultCustomFieldValues(UserGroupID, userGroup.ID, userGroup.CustomFieldValues) + if err != nil { + return nil, err + } + for _, groupCustomFieldValue := range groupCustomFieldValues { + if _, exists := valuesByFieldID[groupCustomFieldValue.CustomFieldID]; !exists { + valuesByFieldID[groupCustomFieldValue.CustomFieldID] = groupCustomFieldValue + } + } + } + + finalCustomFieldValues := make([]model.CustomFieldValue, 0, len(valuesByFieldID)) + for _, customFieldValue := range valuesByFieldID { + finalCustomFieldValues = append(finalCustomFieldValues, customFieldValue) + } + + return finalCustomFieldValues, nil +} + +func (s *CustomFieldValueService) applyDefaultCustomFieldValues(idType idType, ownerID string, customFieldValues []model.CustomFieldValue) ([]model.CustomFieldValue, error) { + fields, err := s.GetConfiguredCustomFieldsForTarget(idType) + if err != nil { + return nil, err + } + + valuesByFieldID := make(map[string]struct{}, len(customFieldValues)) + for _, customFieldValue := range customFieldValues { + valuesByFieldID[customFieldValue.CustomFieldID] = struct{}{} + } + + effectiveCustomFieldValues := append([]model.CustomFieldValue{}, customFieldValues...) + for _, field := range fields { + if field.DefaultValue == "" { + continue + } + if _, ok := valuesByFieldID[field.ID]; ok { + continue + } + + customFieldValue := model.CustomFieldValue{ + CustomFieldID: field.ID, + Value: field.DefaultValue, + } + switch idType { + case UserID: + customFieldValue.UserID = &ownerID + case UserGroupID: + customFieldValue.UserGroupID = &ownerID + } + effectiveCustomFieldValues = append(effectiveCustomFieldValues, customFieldValue) + } + + return effectiveCustomFieldValues, nil +} + +func (s *CustomFieldValueService) GetConfiguredCustomFieldsForTarget(idType idType) ([]dto.CustomFieldDto, error) { + fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value) + if err != nil { + return nil, err + } + + filteredFields := make([]dto.CustomFieldDto, 0, len(fields)) + for _, field := range fields { + if customFieldAppliesTo(field, idType) { + filteredFields = append(filteredFields, field) + } + } + + return filteredFields, nil +} + +func ParseCustomFieldDefinitions(value string) ([]dto.CustomFieldDto, error) { + if value == "" { + return nil, nil + } + + var fields []dto.CustomFieldDto + if err := json.Unmarshal([]byte(value), &fields); err != nil { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("invalid custom fields JSON: %v", err)} + } + + seenIDs := make(map[string]struct{}, len(fields)) + seenKeys := make(map[string]struct{}, len(fields)) + for i, field := range fields { + field.Key = strings.TrimSpace(field.Key) + fields[i].Key = field.Key + + if err := dto.ValidateStruct(field); err != nil { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is invalid: %v", field.Key, err)} + } + + if _, ok := seenIDs[field.ID]; ok { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field id %s is already defined", field.ID)} + } + seenIDs[field.ID] = struct{}{} + if isReservedOIDCClaim(field.Key) { + return nil, &common.ReservedCustomFieldError{Key: field.Key} + } + if _, ok := seenKeys[field.Key]; ok { + return nil, &common.DuplicateCustomFieldError{Key: field.Key} + } + seenKeys[field.Key] = struct{}{} + + if field.ValidationRegex != "" { + if field.Type != dto.CustomFieldTypeString { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s can only use regex validation for text values", field.Key)} + } + } + if field.Required && field.DefaultValue == "" { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s requires a default value", field.Key)} + } + if field.DefaultValue != "" { + if err := validateCustomFieldValue(dto.CustomFieldValueCreateDto{CustomFieldID: field.ID, Value: field.DefaultValue}, field); err != nil { + return nil, err + } + } + } + + return fields, nil +} + +func validateCustomFieldValuesAgainstFields(customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto) ([]dto.CustomFieldValueCreateDto, error) { + fieldsByID := make(map[string]dto.CustomFieldDto, len(fields)) + for _, field := range fields { + fieldsByID[field.ID] = field + } + + valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(customFieldValues)) + for _, customFieldValue := range customFieldValues { + field, ok := fieldsByID[customFieldValue.CustomFieldID] + if !ok { + continue + } + + customFieldValue.CustomFieldID = field.ID + customFieldValue.Key = field.Key + + if _, ok := valuesByFieldID[customFieldValue.CustomFieldID]; ok { + return nil, &common.DuplicateCustomFieldError{Key: field.Key} + } + + if field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" && !field.Required { + continue + } + + if err := validateCustomFieldValue(customFieldValue, field); err != nil { + return nil, err + } + valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue + } + + normalizedCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(valuesByFieldID)) + for _, field := range fields { + customFieldValue, ok := valuesByFieldID[field.ID] + if ok { + normalizedCustomFieldValues = append(normalizedCustomFieldValues, customFieldValue) + continue + } + + if field.DefaultValue != "" { + normalizedCustomFieldValues = append(normalizedCustomFieldValues, dto.CustomFieldValueCreateDto{ + CustomFieldID: field.ID, + Key: field.Key, + Value: field.DefaultValue, + }) + continue + } + + if field.Required { + return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)} + } + } + + return normalizedCustomFieldValues, nil +} + +func validateCustomFieldValue(customFieldValue dto.CustomFieldValueCreateDto, field dto.CustomFieldDto) error { + if field.Required && field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" { + return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)} + } + + switch field.Type { + case dto.CustomFieldTypeString: + if field.ValidationRegex != "" { + matches, err := regexp.MatchString(field.ValidationRegex, customFieldValue.Value) + if err != nil { + return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s has invalid validation regex: %v", field.Key, err)} + } + if !matches { + if field.ValidationErrorMessage != "" { + return &common.CustomFieldValidationError{Message: field.ValidationErrorMessage} + } + return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s does not match the required format", field.Key)} + } + } + return nil + case dto.CustomFieldTypeNumber: + if _, err := strconv.ParseFloat(customFieldValue.Value, 64); err != nil { + return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a number", field.Key)} + } + case dto.CustomFieldTypeBoolean: + if _, err := strconv.ParseBool(customFieldValue.Value); err != nil { + return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a boolean", field.Key)} + } + } + + return nil +} + +func customFieldValueTokenValue(customFieldValue model.CustomFieldValue, field *dto.CustomFieldDto) (any, error) { + if field != nil { + switch field.Type { + case dto.CustomFieldTypeString: + return customFieldValue.Value, nil + case dto.CustomFieldTypeNumber: + value, err := strconv.ParseFloat(customFieldValue.Value, 64) + if err != nil { + return nil, err + } + return value, nil + case dto.CustomFieldTypeBoolean: + value, err := strconv.ParseBool(customFieldValue.Value) + if err != nil { + return nil, err + } + return value, nil + } + } + + var jsonValue any + if err := json.Unmarshal([]byte(customFieldValue.Value), &jsonValue); err == nil { + return jsonValue, nil + } + + return customFieldValue.Value, nil +} diff --git a/backend/internal/service/custom_field_service_test.go b/backend/internal/service/custom_field_service_test.go new file mode 100644 index 00000000..c286c264 --- /dev/null +++ b/backend/internal/service/custom_field_service_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "testing" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCustomFieldDefinitionsValidatesRegex(t *testing.T) { + _, err := ParseCustomFieldDefinitions(`[{"id":"89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03","key":"department","displayName":"Department","type":"string","target":"user","required":false,"validationRegex":"["}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid validation regex") + + _, err = ParseCustomFieldDefinitions(`[{"id":"353555d9-7de8-4320-a10f-5ca4c122a363","key":"age","displayName":"Age","type":"number","target":"user","required":false,"validationRegex":"^[0-9]+$"}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "can only use regex validation for text values") + + _, err = ParseCustomFieldDefinitions(`[{"id":"fe2bc740-6193-4ef2-b1e6-2408a691a98c","key":"department","displayName":"Department","type":"string","target":"user","required":true}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a default value") + + _, err = ParseCustomFieldDefinitions(`[{"id":"be42096c-3dc0-4a9c-8074-086b9f866286","key":"department","displayName":"Department","type":"string","target":"user","required":true,"validationRegex":"^ENG-[0-9]+$","defaultValue":"Sales"}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match the required format") +} + +func TestParseCustomFieldDefinitionsValidatesKey(t *testing.T) { + fields, err := ParseCustomFieldDefinitions(`[{"id":"36c1e786-c9e9-4daf-ab51-502ab8efc9ea","key":" department ","displayName":"Department","type":"string","target":"user","required":false}]`) + require.NoError(t, err) + require.Len(t, fields, 1) + assert.Equal(t, "department", fields[0].Key) + + _, err = ParseCustomFieldDefinitions(`[{"id":"c0e41fb3-59c7-488a-8edb-57e94e9f15ac","key":" ","displayName":"Empty","type":"string","target":"user","required":false}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "custom field key is required") + + _, err = ParseCustomFieldDefinitions(`[{"id":"8b2ff8eb-bcf5-4866-b690-1a5b6f9da56c","key":"email","displayName":"Email","type":"string","target":"user","required":false}]`) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved") +} + +func TestValidateCustomFieldValuesAgainstFieldsAppliesRegex(t *testing.T) { + fields := []dto.CustomFieldDto{ + { + ID: "4ca0513e-e223-4900-8c5e-303acac4d021", + Key: "employee_id", + DisplayName: "Employee ID", + Type: dto.CustomFieldTypeString, + ValidationRegex: "^EMP-[0-9]+$", + ValidationErrorMessage: "Employee ID must start with EMP-", + }, + } + + _, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{ + {CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "INVALID"}, + }, fields) + require.Error(t, err) + assert.Equal(t, "Employee ID must start with EMP-", err.Error()) + + values, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{ + {CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "EMP-123"}, + }, fields) + require.NoError(t, err) + require.Len(t, values, 1) + assert.Equal(t, "4ca0513e-e223-4900-8c5e-303acac4d021", values[0].CustomFieldID) + assert.Equal(t, "EMP-123", values[0].Value) +} + +func TestValidateCustomFieldValuesAgainstFieldsUsesDefaultValue(t *testing.T) { + fields := []dto.CustomFieldDto{ + { + ID: "4225f448-f189-47d5-97d6-90292cc5bf9e", + Key: "department", + DisplayName: "Department", + Type: dto.CustomFieldTypeString, + Required: true, + DefaultValue: "Engineering", + }, + { + ID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579", + Key: "active", + DisplayName: "Active", + Type: dto.CustomFieldTypeBoolean, + Required: true, + DefaultValue: "false", + }, + } + + values, err := validateCustomFieldValuesAgainstFields(nil, fields) + require.NoError(t, err) + require.Len(t, values, 2) + assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "4225f448-f189-47d5-97d6-90292cc5bf9e", Key: "department", Value: "Engineering"}, values[0]) + assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579", Key: "active", Value: "false"}, values[1]) +} + +func TestGetCustomFieldValuesAppliesDefaultValues(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"} + require.NoError(t, db.Create(&user).Error) + group := model.UserGroup{Name: "engineering", FriendlyName: "Engineering"} + require.NoError(t, db.Create(&group).Error) + require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group)) + + appConfigService := NewTestAppConfigService(&model.AppConfig{ + CustomFields: model.AppConfigVariable{Value: `[ + {"id":"81b3c82a-46c8-49c0-9559-a31df8586ef1","key":"department","displayName":"Department","type":"string","target":"user","required":false,"defaultValue":"Engineering"}, + {"id":"b064d601-bc94-4ecf-a5cb-b783f3de0281","key":"group_label","displayName":"Group label","type":"string","target":"group","required":false,"defaultValue":"Employee"} + ]`}, + }) + service := NewCustomFieldValueService(db, appConfigService) + + userValues, err := service.GetCustomFieldValuesForUser(t.Context(), user.ID, db) + require.NoError(t, err) + require.Len(t, userValues, 1) + assert.Equal(t, "81b3c82a-46c8-49c0-9559-a31df8586ef1", userValues[0].CustomFieldID) + assert.Equal(t, "Engineering", userValues[0].Value) + require.NotNil(t, userValues[0].UserID) + + groupValues, err := service.GetCustomFieldValuesForUserGroup(t.Context(), group.ID, db) + require.NoError(t, err) + require.Len(t, groupValues, 1) + assert.Equal(t, "b064d601-bc94-4ecf-a5cb-b783f3de0281", groupValues[0].CustomFieldID) + assert.Equal(t, "Employee", groupValues[0].Value) + require.NotNil(t, groupValues[0].UserGroupID) + + combinedValues, err := service.GetCustomFieldValuesForUserWithUserGroups(t.Context(), user.ID, db) + require.NoError(t, err) + require.Len(t, combinedValues, 2) + valuesByFieldID := map[string]string{} + for _, value := range combinedValues { + valuesByFieldID[value.CustomFieldID] = value.Value + } + assert.Equal(t, "Engineering", valuesByFieldID["81b3c82a-46c8-49c0-9559-a31df8586ef1"]) + assert.Equal(t, "Employee", valuesByFieldID["b064d601-bc94-4ecf-a5cb-b783f3de0281"]) +} + +func TestUpdateSelfEditableCustomFieldValuesForUser(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"} + require.NoError(t, db.Create(&user).Error) + require.NoError(t, db.Create([]model.CustomFieldValue{ + {UserID: &user.ID, CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "old"}, + {UserID: &user.ID, CustomFieldID: "8501e000-09bb-428c-8be3-b0d3b0c682fd", Value: "admin"}, + }).Error) + + appConfigService := NewTestAppConfigService(&model.AppConfig{ + CustomFields: model.AppConfigVariable{Value: `[ + {"id":"608a9c35-2330-433d-bf33-c46f065d5d06","key":"nickname","displayName":"Nickname","type":"string","target":"user","required":false,"userEditable":true}, + {"id":"8501e000-09bb-428c-8be3-b0d3b0c682fd","key":"cost_center","displayName":"Cost center","type":"string","target":"user","required":false,"userEditable":false} + ]`}, + }) + service := NewCustomFieldValueService(db, appConfigService) + + tx := db.Begin() + updatedValues, err := service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{ + {CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "new"}, + }, tx) + require.NoError(t, err) + require.NoError(t, tx.Commit().Error) + + valuesByFieldID := map[string]string{} + for _, value := range updatedValues { + valuesByFieldID[value.CustomFieldID] = value.Value + } + assert.Equal(t, "new", valuesByFieldID["608a9c35-2330-433d-bf33-c46f065d5d06"]) + assert.Equal(t, "admin", valuesByFieldID["8501e000-09bb-428c-8be3-b0d3b0c682fd"]) + + tx = db.Begin() + _, err = service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{ + {CustomFieldID: "invalid", Value: "user"}, + }, tx) + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") + tx.Rollback() + + var costCenter model.CustomFieldValue + require.NoError(t, db.Where("user_id = ? AND custom_field_id = ?", user.ID, "8501e000-09bb-428c-8be3-b0d3b0c682fd").First(&costCenter).Error) + assert.Equal(t, "admin", costCenter.Value) +} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index a86d84dd..ac23889e 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -8,6 +8,7 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/base64" + "encoding/json" "fmt" "log/slog" "path" @@ -17,6 +18,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/pocket-id/pocket-id/backend/internal/dto" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -563,6 +565,56 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error { return err } + // Add custom fields + customFields := []dto.CustomFieldDto{ + { + ID: "189356b1-57f3-4c14-bd59-3ae1132a36d1", + Key: "department", + Type: "string", + UserEditable: true, + DisplayName: "Department", + Target: "user", + ValidationRegex: "^[A-Za-z]+$", + ValidationErrorMessage: "Department must only contain letters", + }, + { + ID: "0b68d19a-bb72-4750-84b4-2f0992f5200c", + Key: "nickname", + Type: "string", + UserEditable: true, + Required: true, + DisplayName: "Nickname", + Target: "user", + DefaultValue: "to-remove", + }, + { + ID: "8d081fd8-6a51-45a1-8051-04c3b043f5bd", + Key: "elevatedRights", + Type: "boolean", + DisplayName: "Elevated Rights", + Target: "group", + }, + { + ID: "3d7c6054-e146-48cb-b2d3-7d7897dcbc51", + Key: "internalId", + Type: "number", + DefaultValue: "0", + UserEditable: false, + DisplayName: "Internal ID", + Target: "both", + Required: true, + }, + } + + customFieldsJSON, err := json.Marshal(&customFields) + if err != nil { + return err + } + err = s.appConfigService.UpdateAppConfigValues(ctx, "customFields", string(customFieldsJSON)) + if err != nil { + return err + } + // Reload the app config from the database after resetting the values err = s.appConfigService.LoadDbConfig(ctx) if err != nil { diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 9de25b30..f1db5670 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -216,8 +216,69 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, } } +func (s *LdapService) getLDAPCustomFields(idType idType) ([]dto.CustomFieldDto, error) { + fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value) + if err != nil { + return nil, err + } + + ldapFields := make([]dto.CustomFieldDto, 0, len(fields)) + for _, field := range fields { + if !customFieldAppliesTo(field, idType) { + continue + } + ldapFields = append(ldapFields, field) + } + + return ldapFields, nil +} + +func appendLDAPCustomFieldAttributes(searchAttrs []string, fields []dto.CustomFieldDto) []string { + seenAttrs := make(map[string]struct{}, len(searchAttrs)+len(fields)) + for _, attr := range searchAttrs { + if attr == "" { + continue + } + seenAttrs[attr] = struct{}{} + } + + for _, field := range fields { + if _, ok := seenAttrs[field.Key]; ok { + continue + } + searchAttrs = append(searchAttrs, field.Key) + seenAttrs[field.Key] = struct{}{} + } + + return searchAttrs +} + +func customFieldValuesFromLDAPEntry(entry *ldap.Entry, fields []dto.CustomFieldDto) []dto.CustomFieldValueCreateDto { + if len(fields) == 0 { + return nil + } + + customFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(fields)) + for _, field := range fields { + value := entry.GetAttributeValue(field.Key) + if value == "" { + continue + } + customFieldValues = append(customFieldValues, dto.CustomFieldValueCreateDto{ + CustomFieldID: field.ID, + Value: value, + }) + } + + return customFieldValues +} + func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) { dbConfig := s.appConfigService.GetDbConfig() + customFields, err := s.getLDAPCustomFields(UserGroupID) + if err != nil { + return nil, nil, err + } // Query LDAP for all groups we want to manage searchAttrs := []string{ @@ -225,6 +286,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient dbConfig.LdapAttributeGroupUniqueIdentifier.Value, dbConfig.LdapAttributeGroupMember.Value, } + searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields) searchReq := ldap.NewSearchRequest( dbConfig.LdapBase.Value, @@ -267,9 +329,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient } syncGroup := dto.UserGroupCreateDto{ - Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), - FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), - LdapID: ldapID, + Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), + FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), + LdapID: ldapID, + CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields), } dto.Normalize(&syncGroup) @@ -291,6 +354,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { dbConfig := s.appConfigService.GetDbConfig() + customFields, err := s.getLDAPCustomFields(UserID) + if err != nil { + return nil, nil, nil, err + } // Query LDAP for all users we want to manage searchAttrs := []string{ @@ -304,6 +371,7 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) dbConfig.LdapAttributeUserProfilePicture.Value, dbConfig.LdapAttributeUserDisplayName.Value, } + searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields) // Filters must start and finish with ()! searchReq := ldap.NewSearchRequest( @@ -342,12 +410,13 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) ldapUserIDs[ldapID] = struct{}{} newUser := dto.UserCreateDto{ - Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), - Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), - EmailVerified: true, - FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), - LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), - DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), + Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), + Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), + EmailVerified: true, + FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), + LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), + DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), + CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields), // Admin status is computed after groups are loaded so it can use the // configured group member attribute instead of a hard-coded memberOf. IsAdmin: false, @@ -423,6 +492,11 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda } func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error { + customFields, err := s.getLDAPCustomFields(UserGroupID) + if err != nil { + return err + } + // Load the current LDAP-managed state from the database ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx) if err != nil { @@ -448,28 +522,38 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG } databaseGroup := ldapGroupsByID[desiredGroup.ldapID] + var groupID string if databaseGroup.ID == "" { newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx) if err != nil { return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err) } ldapGroupsByID[desiredGroup.ldapID] = newGroup + groupID = newGroup.ID _, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx) if err != nil { return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err) } - continue + } else { + groupID = databaseGroup.ID + + _, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx) + if err != nil { + return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err) + } + + _, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx) + if err != nil { + return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err) + } } - _, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx) - if err != nil { - return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err) - } - - _, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx) - if err != nil { - return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err) + if len(customFields) > 0 { + _, err = s.groupService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserGroupID, groupID, desiredGroup.input.CustomFieldValues, customFields, tx) + if err != nil { + return fmt.Errorf("failed to sync custom fields for group '%s': %w", desiredGroup.input.Name, err) + } } } @@ -500,6 +584,10 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG //nolint:gocognit func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) { dbConfig := s.appConfigService.GetDbConfig() + customFields, err := s.getLDAPCustomFields(UserID) + if err != nil { + return nil, nil, err + } // Load the current LDAP-managed state from the database ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx) @@ -551,6 +639,11 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs } } + _, err = s.userService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserID, userID, desiredUser.input.CustomFieldValues, customFields, tx) + if err != nil { + return nil, nil, fmt.Errorf("failed to sync custom fields for user '%s': %w", desiredUser.input.Username, err) + } + if desiredUser.picture != "" { savePictures = append(savePictures, savePicture{ userID: userID, diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index d340cc74..837fe6e9 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -141,6 +141,51 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) { assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users)) } +func TestLdapServiceSyncAllImportsCustomFieldsFromLDAP(t *testing.T) { + appCfg := defaultTestLDAPAppConfig() + appCfg.CustomFields = model.AppConfigVariable{Value: `[ + {"id":"5b6f0cb7-2865-4c2e-9795-4c81e3725f21","key":"quota","displayName":"Quota","type":"string","target":"user","required":false}, + {"id":"5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644","key":"mailboxTemplate","displayName":"Mailbox template","type":"string","target":"user","required":false}, + {"id":"9a98fcfb-1d0b-46a3-b028-3c43694b1771","key":"nextcloudQuota","displayName":"Group quota","type":"string","target":"group","required":false} + ]`} + + service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient( + ldapSearchResult( + ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{ + "entryUUID": {"u-alice"}, + "uid": {"alice"}, + "mail": {"alice@example.com"}, + "givenName": {"Alice"}, + "sn": {"Jones"}, + "displayName": {""}, + "quota": {"10 GB"}, + "mailboxTemplate": {"standard"}, + }), + ), + ldapSearchResult( + ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{ + "entryUUID": {"g-team"}, + "cn": {"team"}, + "member": {"uid=alice,ou=people,dc=example,dc=com"}, + "nextcloudQuota": {"100 GB"}, + }), + ), + )) + + require.NoError(t, service.SyncAll(t.Context())) + + var alice model.User + require.NoError(t, db.Preload("CustomFieldValues").First(&alice, "ldap_id = ?", "u-alice").Error) + userValues := customFieldValuesByID(alice.CustomFieldValues) + assert.Equal(t, "10 GB", userValues["5b6f0cb7-2865-4c2e-9795-4c81e3725f21"]) + assert.Equal(t, "standard", userValues["5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644"]) + + var group model.UserGroup + require.NoError(t, db.Preload("CustomFieldValues").First(&group, "ldap_id = ?", "g-team").Error) + groupValues := customFieldValuesByID(group.CustomFieldValues) + assert.Equal(t, "100 GB", groupValues["9a98fcfb-1d0b-46a3-b028-3c43694b1771"]) +} + // Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408. func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) { appCfg := defaultTestLDAPAppConfig() @@ -318,14 +363,15 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf appConfig := NewTestAppConfigService(appConfigModel) - groupService := NewUserGroupService(db, appConfig, nil) + customFieldValueService := NewCustomFieldValueService(db, appConfig) + groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil) userService := NewUserService( db, nil, nil, nil, appConfig, - NewCustomClaimService(db), + customFieldValueService, NewAppImagesService(map[string]string{}, fileStorage), nil, fileStorage, @@ -405,6 +451,15 @@ func usernames(users []model.User) []string { return result } +func customFieldValuesByID(values []model.CustomFieldValue) map[string]string { + result := make(map[string]string, len(values)) + for _, value := range values { + result[value.CustomFieldID] = value.Value + } + + return result +} + func TestGetDNProperty(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d58d4c49..4c7dd007 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -6,7 +6,6 @@ import ( "crypto/subtle" "crypto/tls" "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -51,13 +50,13 @@ const ( ) type OidcService struct { - db *gorm.DB - jwtService *JwtService - appConfigService *AppConfigService - auditLogService *AuditLogService - customClaimService *CustomClaimService - webAuthnService *WebAuthnService - scimService *ScimService + db *gorm.DB + jwtService *JwtService + appConfigService *AppConfigService + auditLogService *AuditLogService + customFieldValueService *CustomFieldValueService + webAuthnService *WebAuthnService + scimService *ScimService httpClient *http.Client jwkCache *jwk.Cache @@ -70,22 +69,22 @@ func NewOidcService( jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, - customClaimService *CustomClaimService, + customFieldValueService *CustomFieldValueService, webAuthnService *WebAuthnService, scimService *ScimService, httpClient *http.Client, fileStorage storage.FileStorage, ) (s *OidcService, err error) { s = &OidcService{ - db: db, - jwtService: jwtService, - appConfigService: appConfigService, - auditLogService: auditLogService, - customClaimService: customClaimService, - webAuthnService: webAuthnService, - scimService: scimService, - httpClient: httpClient, - fileStorage: fileStorage, + db: db, + jwtService: jwtService, + appConfigService: appConfigService, + auditLogService: auditLogService, + customFieldValueService: customFieldValueService, + webAuthnService: webAuthnService, + scimService: scimService, + httpClient: httpClient, + fileStorage: fileStorage, } // Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace @@ -2043,23 +2042,51 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope } if slices.Contains(scopes, "profile") { - // Add custom claims - customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx) + // We need to fetch the user and group fields first because we need the key of the custom key later + userFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserID) + if err != nil { + return nil, err + } + userFieldsByID := make(map[string]dto.CustomFieldDto, len(userFields)) + for _, customField := range userFields { + userFieldsByID[customField.ID] = customField + } + + groupFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserGroupID) + if err != nil { + return nil, err + } + groupFieldsByID := make(map[string]dto.CustomFieldDto, len(groupFields)) + for _, customField := range groupFields { + groupFieldsByID[customField.ID] = customField + } + + // Fetch the actual values of the custom fields + customFieldValues, err := s.customFieldValueService.GetCustomFieldValuesForUserWithUserGroups(ctx, user.ID, tx) if err != nil { return nil, err } - for _, customClaim := range customClaims { - // The value of the custom claim can be a JSON object or a string - var jsonValue any - err := json.Unmarshal([]byte(customClaim.Value), &jsonValue) - if err == nil { - // It's JSON, so we store it as an object - claims[customClaim.Key] = jsonValue - } else { - // Marshaling failed, so we store it as a string - claims[customClaim.Key] = customClaim.Value + for _, customFieldValue := range customFieldValues { + var customField *dto.CustomFieldDto + var claimKey string + if customFieldValue.UserID != nil { + if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok { + customField = &field + claimKey = field.Key + } + } else if customFieldValue.UserGroupID != nil { + if field, ok := groupFieldsByID[customFieldValue.CustomFieldID]; ok { + customField = &field + claimKey = field.Key + } } + + value, err := customFieldValueTokenValue(customFieldValue, customField) + if err != nil { + return nil, err + } + claims[claimKey] = value } // Add profile claims diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 0e37a5ac..0781059b 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -15,19 +15,24 @@ import ( ) type UserGroupService struct { - db *gorm.DB - scimService *ScimService - appConfigService *AppConfigService + db *gorm.DB + scimService *ScimService + appConfigService *AppConfigService + customFieldValueService *CustomFieldValueService } -func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService { - return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService} +func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService { + return &UserGroupService{db: db, appConfigService: appConfigService, customFieldValueService: customFieldValueService, scimService: scimService} } func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) { - query := s.db. + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + query := tx. WithContext(ctx). - Preload("CustomClaims"). Model(&model.UserGroup{}) if name != "" { @@ -43,6 +48,17 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt } response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups) + if err != nil { + return nil, utils.PaginationResponse{}, err + } + + for i := range groups { + groups[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, groups[i].ID, tx) + if err != nil { + return nil, utils.PaginationResponse{}, err + } + } + return groups, response, err } @@ -54,11 +70,19 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm. err = tx. WithContext(ctx). Where("id = ?", id). - Preload("CustomClaims"). Preload("Users"). Preload("AllowedOidcClients"). First(&group). Error + if err != nil { + return model.UserGroup{}, err + } + + group.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, group.ID, tx) + if err != nil { + return model.UserGroup{}, err + } + return group, err } @@ -104,7 +128,22 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error { } func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) { - return s.createInternal(ctx, input, s.db) + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + group, err = s.createInternal(ctx, input, tx) + if err != nil { + return model.UserGroup{}, err + } + + err = tx.Commit().Error + if err != nil { + return model.UserGroup{}, err + } + + return group, nil } func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) { @@ -129,6 +168,12 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro return model.UserGroup{}, err } + if input.LdapID == "" { + if group.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil { + return model.UserGroup{}, err + } + } + if s.scimService != nil { s.scimService.ScheduleSync() } @@ -181,6 +226,12 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input return model.UserGroup{}, err } + if input.CustomFieldValues != nil { + if _, err := s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil { + return model.UserGroup{}, err + } + } + if s.scimService != nil { s.scimService.ScheduleSync() } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 8899d68e..0b2b47d7 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -27,37 +27,41 @@ import ( ) type UserService struct { - db *gorm.DB - jwtService *JwtService - auditLogService *AuditLogService - emailService *EmailService - appConfigService *AppConfigService - customClaimService *CustomClaimService - appImagesService *AppImagesService - scimService *ScimService - fileStorage storage.FileStorage + db *gorm.DB + jwtService *JwtService + auditLogService *AuditLogService + emailService *EmailService + appConfigService *AppConfigService + customFieldValueService *CustomFieldValueService + appImagesService *AppImagesService + scimService *ScimService + fileStorage storage.FileStorage } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { return &UserService{ - db: db, - jwtService: jwtService, - auditLogService: auditLogService, - emailService: emailService, - appConfigService: appConfigService, - customClaimService: customClaimService, - appImagesService: appImagesService, - scimService: scimService, - fileStorage: fileStorage, + db: db, + jwtService: jwtService, + auditLogService: auditLogService, + emailService: emailService, + appConfigService: appConfigService, + customFieldValueService: customFieldValueService, + appImagesService: appImagesService, + scimService: scimService, + fileStorage: fileStorage, } } func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + var users []model.User - query := s.db.WithContext(ctx). + query := tx.WithContext(ctx). Model(&model.User{}). - Preload("UserGroups"). - Preload("CustomClaims") + Preload("UserGroups") if searchTerm != "" { searchPattern := "%" + searchTerm + "%" @@ -67,6 +71,16 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequ } pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users) + if err != nil { + return nil, utils.PaginationResponse{}, err + } + + for i := range users { + users[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, users[i].ID, tx) + if err != nil { + return nil, utils.PaginationResponse{}, err + } + } return users, pagination, err } @@ -80,10 +94,17 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go err := tx. WithContext(ctx). Preload("UserGroups"). - Preload("CustomClaims"). Where("id = ?", userID). First(&user). Error + if err != nil { + return model.User{}, err + } + + user.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, user.ID, tx) + if err != nil { + return model.User{}, err + } return user, err } @@ -301,15 +322,17 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea return model.User{}, err } - // Apply default groups and claims for new non-LDAP users + // Apply default groups and custom fields for new non-LDAP users. if !isLdapSync { if len(input.UserGroupIds) == 0 { if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { return model.User{}, err } } - - if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { + } + if !isLdapSync { + user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, input.CustomFieldValues, tx) + if err != nil { return model.User{}, err } } @@ -353,27 +376,6 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, return nil } -func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { - config := s.appConfigService.GetDbConfig() - - var claims []dto.CustomClaimCreateDto - v := config.SignupDefaultCustomClaims.Value - if v != "" && v != "[]" { - err := json.Unmarshal([]byte(v), &claims) - if err != nil { - return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err) - } - if len(claims) > 0 { - _, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx) - if err != nil { - return fmt.Errorf("failed to apply default custom claims: %w", err) - } - } - } - - return nil -} - func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) { tx := s.db.Begin() defer func() { @@ -463,6 +465,15 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd return user, err } + if updateOwnUser { + user.CustomFieldValues, err = s.customFieldValueService.updateSelfEditableCustomFieldValuesForUser(ctx, user.ID, updatedUser.CustomFieldValues, tx) + } else { + user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, updatedUser.CustomFieldValues, tx) + } + if err != nil { + return user, err + } + if s.scimService != nil { s.scimService.ScheduleSync() } diff --git a/backend/internal/service/user_signup_service.go b/backend/internal/service/user_signup_service.go index b733762e..6deeccf4 100644 --- a/backend/internal/service/user_signup_service.go +++ b/backend/internal/service/user_signup_service.go @@ -72,14 +72,20 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto } } + customFieldValues, err := s.filterSignupCustomFieldValues(signupData.CustomFieldValues) + if err != nil { + return model.User{}, "", err + } + userToCreate := dto.UserCreateDto{ - Username: signupData.Username, - Email: signupData.Email, - FirstName: signupData.FirstName, - LastName: signupData.LastName, - DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), - UserGroupIds: userGroupIDs, - EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(), + Username: signupData.Username, + Email: signupData.Email, + FirstName: signupData.FirstName, + LastName: signupData.LastName, + DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), + UserGroupIds: userGroupIDs, + EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(), + CustomFieldValues: customFieldValues, } user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx) @@ -132,13 +138,19 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d return model.User{}, "", &common.SetupNotAvailableError{} } + customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues) + if err != nil { + return model.User{}, "", err + } + userToCreate := dto.UserCreateDto{ - FirstName: signUpData.FirstName, - LastName: signUpData.LastName, - DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName), - Username: signUpData.Username, - Email: signUpData.Email, - IsAdmin: true, + FirstName: signUpData.FirstName, + LastName: signUpData.LastName, + DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName), + Username: signUpData.Username, + Email: signUpData.Email, + IsAdmin: true, + CustomFieldValues: customFieldValues, } user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx) @@ -159,6 +171,34 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d return user, token, nil } +func (s *UserSignUpService) filterSignupCustomFieldValues(customFieldValues []dto.CustomFieldValueCreateDto) ([]dto.CustomFieldValueCreateDto, error) { + fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value) + if err != nil { + return nil, err + } + + allowedFieldIDs := make(map[string]struct{}, len(fields)) + allowedFieldKeys := make(map[string]struct{}, len(fields)) + for _, field := range fields { + if !customFieldAppliesTo(field, UserID) || (!field.Required && !field.UserEditable) { + continue + } + allowedFieldIDs[field.ID] = struct{}{} + allowedFieldKeys[field.Key] = struct{}{} + } + + filteredCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(customFieldValues)) + for _, customFieldValue := range customFieldValues { + _, idAllowed := allowedFieldIDs[customFieldValue.CustomFieldID] + _, keyAllowed := allowedFieldKeys[customFieldValue.Key] + if idAllowed || keyAllowed { + filteredCustomFieldValues = append(filteredCustomFieldValues, customFieldValue) + } + } + + return filteredCustomFieldValues, nil +} + func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) { return s.isInitialAdminSetupCompleted(ctx, s.db) } diff --git a/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.down.sql b/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.down.sql new file mode 100644 index 00000000..4710bba8 --- /dev/null +++ b/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.down.sql @@ -0,0 +1,31 @@ +CREATE TEMP TABLE custom_field_migration_map AS +SELECT + field.value->>'id' AS custom_field_id, + field.value->>'key' AS key +FROM app_config_variables +CROSS JOIN LATERAL jsonb_array_elements(app_config_variables.value::jsonb) AS field(value) +WHERE app_config_variables.key = 'customFields'; + +ALTER TABLE custom_field_values RENAME CONSTRAINT custom_field_values_unique TO custom_field_values_custom_field_id_unique; + +ALTER TABLE custom_field_values ADD COLUMN key VARCHAR(255); + +UPDATE custom_field_values +SET key = custom_field_migration_map.key +FROM custom_field_migration_map +WHERE custom_field_migration_map.custom_field_id = custom_field_values.custom_field_id; + +UPDATE custom_field_values +SET key = custom_field_id +WHERE key IS NULL; + +ALTER TABLE custom_field_values ALTER COLUMN key SET NOT NULL; +ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_custom_field_id_unique; +ALTER TABLE custom_field_values DROP COLUMN custom_field_id; +ALTER TABLE custom_field_values ADD CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id); + +ALTER TABLE custom_field_values RENAME TO custom_claims; + +DROP TABLE custom_field_migration_map; + +DELETE FROM app_config_variables WHERE key = 'customFields'; diff --git a/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.up.sql b/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.up.sql new file mode 100644 index 00000000..61434212 --- /dev/null +++ b/backend/resources/migrations/postgres/20260519120000_custom_field_values_table.up.sql @@ -0,0 +1,61 @@ +ALTER TABLE custom_claims RENAME TO custom_field_values; +ALTER TABLE custom_field_values RENAME CONSTRAINT custom_claims_unique TO custom_field_values_key_unique; + +ALTER TABLE custom_field_values ADD COLUMN custom_field_id VARCHAR(255); + +CREATE TEMP TABLE custom_field_migration_map AS +SELECT + key, + SUBSTRING(md5(key) FROM 1 FOR 8) || '-' || + SUBSTRING(md5(key) FROM 9 FOR 4) || '-' || + '4' || SUBSTRING(md5(key) FROM 14 FOR 3) || '-' || + '8' || SUBSTRING(md5(key) FROM 18 FOR 3) || '-' || + SUBSTRING(md5(key) FROM 21 FOR 12) AS custom_field_id, + BOOL_OR(user_id IS NOT NULL) AS has_user_values, + BOOL_OR(user_group_id IS NOT NULL) AS has_group_values +FROM custom_field_values +GROUP BY key; + +INSERT INTO app_config_variables (key, value) +SELECT + 'customFields', + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', custom_field_id, + 'key', key, + 'displayName', key, + 'type', 'string', + 'target', + CASE + WHEN has_user_values AND has_group_values THEN 'both' + WHEN has_user_values THEN 'user' + ELSE 'group' + END, + 'required', false, + 'userEditable', false, + 'defaultValue', '', + 'validationRegex', '', + 'validationErrorMessage', '' + ) + ORDER BY key + )::TEXT, + '[]' + ) +FROM custom_field_migration_map +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value +WHERE app_config_variables.value = '' OR app_config_variables.value = '[]'; + +UPDATE custom_field_values +SET custom_field_id = custom_field_migration_map.custom_field_id +FROM custom_field_migration_map +WHERE custom_field_migration_map.key = custom_field_values.key; + +ALTER TABLE custom_field_values ALTER COLUMN custom_field_id SET NOT NULL; +ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_key_unique; +ALTER TABLE custom_field_values DROP COLUMN key; +ALTER TABLE custom_field_values ADD CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id); + +DROP TABLE custom_field_migration_map; + +DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields'); diff --git a/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.down.sql b/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.down.sql new file mode 100644 index 00000000..8b1baf22 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.down.sql @@ -0,0 +1,40 @@ +CREATE TEMP TABLE custom_field_migration_map AS +SELECT + json_extract(json_each.value, '$.id') AS custom_field_id, + json_extract(json_each.value, '$.key') AS key +FROM app_config_variables, json_each(app_config_variables.value) +WHERE app_config_variables.key = 'customFields'; + +ALTER TABLE custom_field_values RENAME TO custom_field_values_old; + +CREATE TABLE custom_claims +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + key TEXT NOT NULL, + value TEXT NOT NULL, + + user_id TEXT, + user_group_id TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE, + + CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id), + CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL) +); + +INSERT INTO custom_claims (id, created_at, key, value, user_id, user_group_id) +SELECT + old.id, + old.created_at, + COALESCE(map.key, old.custom_field_id), + old.value, + old.user_id, + old.user_group_id +FROM custom_field_values_old old +LEFT JOIN custom_field_migration_map map ON map.custom_field_id = old.custom_field_id; + +DROP TABLE custom_field_values_old; +DROP TABLE custom_field_migration_map; + +DELETE FROM app_config_variables WHERE key = 'customFields'; diff --git a/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.up.sql b/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.up.sql new file mode 100644 index 00000000..d87835b6 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260519120000_custom_field_values_table.up.sql @@ -0,0 +1,73 @@ +ALTER TABLE custom_claims RENAME TO custom_field_values_old; + +CREATE TEMP TABLE custom_field_migration_map AS +SELECT + key, + lower(hex(randomblob(4))) || '-' || + lower(hex(randomblob(2))) || '-' || + '4' || substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || + lower(hex(randomblob(6))) AS custom_field_id, + MAX(user_id IS NOT NULL) AS has_user_values, + MAX(user_group_id IS NOT NULL) AS has_group_values +FROM custom_field_values_old +GROUP BY key; + +INSERT INTO app_config_variables (key, value) +VALUES ( + 'customFields', + COALESCE( + ( + SELECT json_group_array( + json_object( + 'id', custom_field_id, + 'key', key, + 'displayName', key, + 'type', 'string', + 'target', + CASE + WHEN has_user_values = 1 AND has_group_values = 1 THEN 'both' + WHEN has_user_values = 1 THEN 'user' + ELSE 'group' + END, + 'required', json('false'), + 'userEditable', json('false'), + 'defaultValue', '', + 'validationRegex', '', + 'validationErrorMessage', '' + ) + ) + FROM custom_field_migration_map + ORDER BY key + ), + '[]' + ) +) +ON CONFLICT(key) DO UPDATE SET value = excluded.value +WHERE app_config_variables.value = '' OR app_config_variables.value = '[]'; + +CREATE TABLE custom_field_values +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + custom_field_id TEXT NOT NULL, + value TEXT NOT NULL, + + user_id TEXT, + user_group_id TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE, + + CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id), + CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL) +); + +INSERT INTO custom_field_values (id, created_at, custom_field_id, value, user_id, user_group_id) +SELECT old.id, old.created_at, map.custom_field_id, old.value, old.user_id, old.user_group_id +FROM custom_field_values_old old +JOIN custom_field_migration_map map ON map.key = old.key; + +DROP TABLE custom_field_values_old; +DROP TABLE custom_field_migration_map; + +DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields'); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 43f249ad..3b967781 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -5,9 +5,6 @@ "confirm": "Confirm", "docs": "Docs", "key": "Key", - "value": "Value", - "remove_custom_claim": "Remove custom claim", - "add_custom_claim": "Add custom claim", "add_another": "Add another", "select_a_date": "Select a date", "select_file": "Select File", @@ -35,7 +32,6 @@ "one_week": "1 week", "one_month": "1 month", "expiration": "Expiration", - "generate_code": "Generate Code", "name": "Name", "browser_unsupported": "Browser unsupported", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.", @@ -75,7 +71,6 @@ "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate": "Authenticate", "please_try_again": "Please try again.", - "continue": "Continue", "alternative_sign_in": "Alternative Sign In", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.", "use_your_passkey_instead": "Use your passkey instead?", @@ -169,7 +164,6 @@ "ldap": "LDAP", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "images": "Images", - "update": "Update", "email_configuration_updated_successfully": "Email configuration updated successfully", "save_changes_question": "Save changes?", "you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?", @@ -249,12 +243,9 @@ "edit": "Edit", "user_groups_updated_successfully": "User groups updated successfully", "user_updated_successfully": "User updated successfully", - "custom_claims_updated_successfully": "Custom claims updated successfully", "back": "Back", "user_details_firstname_lastname": "User Details {firstName} {lastName}", "manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.", - "custom_claims": "Custom Claims", - "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.", "user_group_created_successfully": "User group created successfully", "create_user_group": "Create User Group", "create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.", @@ -271,7 +262,6 @@ "users_updated_successfully": "Users updated successfully", "user_group_details_name": "User Group Details {name}", "assign_users_to_this_group": "Assign users to this group.", - "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.", "oidc_client_created_successfully": "OIDC client created successfully", "create_oidc_client": "Create OIDC Client", "add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.", @@ -289,9 +279,7 @@ "requires_reauthentication": "Requires Re-Authentication", "requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in", "name_logo": "{name} logo", - "change_logo": "Change Logo", "upload_logo": "Upload Logo", - "remove_logo": "Remove Logo", "are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?", "oidc_client_deleted_successfully": "OIDC client deleted successfully", "authorization_url": "Authorization URL", @@ -373,7 +361,6 @@ "add_federated_client_credential": "Add Federated Client Credential", "add_another_federated_client_credential": "Add another federated client credential", "oidc_allowed_group_count": "Allowed Group Count", - "unrestricted": "Unrestricted", "show_advanced_options": "Show Advanced Options", "hide_advanced_options": "Hide Advanced Options", "oidc_data_preview": "OIDC Data Preview", @@ -385,9 +372,7 @@ "access_token_payload": "Access Token Payload", "userinfo_endpoint_response": "Userinfo Endpoint Response", "copy": "Copy", - "no_preview_data_available": "No preview data available", "copy_all": "Copy All", - "preview": "Preview", "preview_for_user": "Preview for {name}", "preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "show": "Show", @@ -409,11 +394,9 @@ "user_creation": "User Creation", "configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.", "user_creation_groups_description": "Assign these groups automatically to new users upon signup.", - "user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.", "user_creation_updated_successfully": "User creation settings updated successfully.", "signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_requires_valid_token": "A valid signup token is required to create an account", - "validating_signup_token": "Validating signup token", "go_to_login": "Go to login", "signup_to_appname": "Sign Up to {appName}", "create_your_account_to_get_started": "Create your account to get started.", @@ -436,7 +419,6 @@ "usage": "Usage", "created": "Created", "token": "Token", - "loading": "Loading", "delete_signup_token": "Delete Signup Token", "are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.", "signup_with_token": "Signup with token", @@ -526,5 +508,34 @@ "email_verification_sent": "Verification email sent successfully.", "emails_verified_by_default": "Emails verified by default", "emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.", - "user_has_no_passkeys_yet": "This user has no passkeys yet." + "user_has_no_passkeys_yet": "This user has no passkeys yet.", + "custom_fields": "Custom Fields", + "configure_custom_fields": "Configure custom fields that can be assigned to users and user groups.", + "custom_fields_updated_successfully": "Custom fields updated successfully", + "add_custom_field": "Add custom field", + "delete_custom_field_name": "Delete {name}?", + "delete_custom_field_description": "Are you sure you want to delete the custom field {name}? This will remove the field from all users and groups.", + "custom_field_already_exists": "Custom field already exists", + "available_for": "Available for", + "users_and_groups": "Users and groups", + "field_is_required": "Field is required", + "must_be_a_number": "Must be a number", + "value_must_be_true_or_false": "Value must be true or false", + "default_value": "Default value", + "no_default": "No default", + "invalid_regex": "Invalid regex", + "validation_regex": "Validation regex", + "validation_regex_description": "A regular expression that the value of this field must match.", + "must_be_a_valid_email_address": "Must be a valid email address", + "validation_error_message": "Validation error message", + "validation_error_message_description": "The error message that will be shown when the value does not match the validation regex.", + "value_does_not_match_required_format": "Value does not match the required format", + "user_editable": "User editable", + "user_editable_description": "Allow users to update this field on their own account.", + "text": "Text", + "number": "Number", + "boolean": "Boolean", + "type": "Type", + "required": "Required", + "required_custom_field_description": "Whether this field should be marked as required in the form" } diff --git a/frontend/src/lib/components/collapsible-card.svelte b/frontend/src/lib/components/collapsible-card.svelte index 60ee45c2..054af5c0 100644 --- a/frontend/src/lib/components/collapsible-card.svelte +++ b/frontend/src/lib/components/collapsible-card.svelte @@ -13,6 +13,7 @@ title, description, defaultExpanded = false, + noHeaderMargin = false, forcedExpanded, button, icon, @@ -22,6 +23,7 @@ title: string; description?: string; defaultExpanded?: boolean; + noHeaderMargin?: boolean; forcedExpanded?: boolean; icon?: typeof IconType; button?: Snippet; @@ -60,7 +62,11 @@ }); - +
diff --git a/frontend/src/lib/components/form/custom-claims-input.svelte b/frontend/src/lib/components/form/custom-claims-input.svelte deleted file mode 100644 index 5b56ab55..00000000 --- a/frontend/src/lib/components/form/custom-claims-input.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -
- -
- {#each customClaims as _, i} -
- - - -
- {/each} -
-
- {#if error} -

{error}

- {/if} - {#if customClaims.length < limit} - - {/if} -
diff --git a/frontend/src/lib/components/form/custom-field-values-input.svelte b/frontend/src/lib/components/form/custom-field-values-input.svelte new file mode 100644 index 00000000..8eca8f99 --- /dev/null +++ b/frontend/src/lib/components/form/custom-field-values-input.svelte @@ -0,0 +1,110 @@ + + +{#each customFields as field (field.key)} + + + {field.displayName || field.key} + + {#if field.type === 'boolean'} +
+ setValue(field, checked ? 'true' : 'false')} + /> +
+ {:else} + setValue(field, e.currentTarget.value)} + /> + {/if} + {#if errors[field.key]} + {errors[field.key]} + {/if} +
+{/each} diff --git a/frontend/src/lib/components/form/switch-with-label.svelte b/frontend/src/lib/components/form/switch-with-label.svelte index cc7cfc5c..1a7110dd 100644 --- a/frontend/src/lib/components/form/switch-with-label.svelte +++ b/frontend/src/lib/components/form/switch-with-label.svelte @@ -1,9 +1,11 @@ -
+
+ import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte'; import { m } from '$lib/paraglide/messages'; import appConfigStore from '$lib/stores/application-configuration-store'; + import { customFieldAppliesTo, type CustomFieldValue } from '$lib/types/custom-field.type'; import type { UserSignUp } from '$lib/types/user.type'; import { preventDefault } from '$lib/utils/event-util'; import { createForm } from '$lib/utils/form-util'; @@ -35,16 +37,23 @@ const { inputs, ...form } = createForm(formSchema, initialData); - let userData: UserSignUp | null = $state(null); + let customFieldValues = $state([]); + let customFieldValuesInputRef = $state(); + let customFields = $derived( + $appConfigStore.customFields.filter( + (field) => customFieldAppliesTo(field, 'user') && (field.required && field.userEditable) + ) + ); async function onSubmit() { const data = form.validate(); if (!data) return; + if (customFieldValuesInputRef && !customFieldValuesInputRef.validate()) return; isLoading = true; - const result = await tryCatch(callback(data)); + const result = await tryCatch(callback({ ...data, customFieldValues })); if (result.data) { - userData = data; + userData = { ...data, customFieldValues }; isLoading = false; } } @@ -58,6 +67,11 @@
+
diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte index 98a393d9..d1625250 100644 --- a/frontend/src/lib/components/table/advanced-table.svelte +++ b/frontend/src/lib/components/table/advanced-table.svelte @@ -25,6 +25,7 @@ selectedIds = $bindable(), withoutSearch = false, selectionDisabled = false, + paginationDisabled = false, rowSelectionDisabled, fetchCallback, defaultSort, @@ -35,6 +36,7 @@ selectedIds?: string[]; withoutSearch?: boolean; selectionDisabled?: boolean; + paginationDisabled?: boolean; rowSelectionDisabled?: (item: T) => boolean; fetchCallback: (requestOptions: ListRequestOptions) => Promise>; defaultSort?: SortRequest; @@ -178,8 +180,10 @@ export async function refresh() { items = await fetchCallback(requestOptions); - changePageState(items.pagination.currentPage); - updateListLength(items.pagination.totalItems); + if (!paginationDisabled) { + changePageState(items.pagination.currentPage); + updateListLength(items.pagination.totalItems); + } } @@ -331,51 +335,52 @@
{/if} - -
-
-

{m.items_per_page()}

- onPageSizeChange(Number(v))} + {#if !paginationDisabled} +
+
+

{m.items_per_page()}

+ onPageSizeChange(Number(v))} + > + + {items?.pagination.itemsPerPage} + + + {#each availablePageSizes as size} + {size} + {/each} + + +
+ - - {items?.pagination.itemsPerPage} - - - {#each availablePageSizes as size} - {size} - {/each} - - + {#snippet children({ pages })} + + + + + {#each pages as page (page.key)} + {#if page.type !== 'ellipsis' && page.value != 0} + + + {page.value} + + + {/if} + {/each} + + + + + {/snippet} +
- - {#snippet children({ pages })} - - - - - {#each pages as page (page.key)} - {#if page.type !== 'ellipsis' && page.value != 0} - - - {page.value} - - - {/if} - {/each} - - - - - {/snippet} - -
+ {/if} {/if} diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte index a1020a70..550bd477 100644 --- a/frontend/src/lib/components/ui/tabs/tabs.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -1,18 +1,36 @@ { - const res = await this.api.get('/custom-claims/suggestions'); - return res.data as string[]; - }; - - updateUserCustomClaims = async (userId: string, claims: CustomClaim[]) => { - const res = await this.api.put(`/custom-claims/user/${userId}`, claims); - return res.data as CustomClaim[]; - }; - - updateUserGroupCustomClaims = async (userGroupId: string, claims: CustomClaim[]) => { - const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims); - return res.data as CustomClaim[]; - }; -} diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts index 011fe5b7..5db4e69d 100644 --- a/frontend/src/lib/types/application-configuration.type.ts +++ b/frontend/src/lib/types/application-configuration.type.ts @@ -1,4 +1,4 @@ -import type { CustomClaim } from './custom-claim.type'; +import type { CustomFieldValue, CustomField } from './custom-field.type'; export type AppConfig = { appName: string; @@ -13,6 +13,7 @@ export type AppConfig = { uiConfigDisabled: boolean; accentColor: string; requireUserEmail: boolean; + customFields: CustomField[]; }; export type AllAppConfig = AppConfig & { @@ -20,7 +21,6 @@ export type AllAppConfig = AppConfig & { sessionDuration: number; emailsVerified: boolean; signupDefaultUserGroupIDs: string[]; - signupDefaultCustomClaims: CustomClaim[]; // Email smtpHost: string; smtpPort: string; diff --git a/frontend/src/lib/types/custom-claim.type.ts b/frontend/src/lib/types/custom-claim.type.ts deleted file mode 100644 index 24a36596..00000000 --- a/frontend/src/lib/types/custom-claim.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type CustomClaim = { - key: string; - value: string; -}; diff --git a/frontend/src/lib/types/custom-field.type.ts b/frontend/src/lib/types/custom-field.type.ts new file mode 100644 index 00000000..24738f6e --- /dev/null +++ b/frontend/src/lib/types/custom-field.type.ts @@ -0,0 +1,24 @@ +export type CustomFieldValue = { + customFieldId: string; + value: string; +}; + +export type CustomFieldType = 'string' | 'number' | 'boolean'; +export type CustomFieldTarget = 'user' | 'group' | 'both'; + +export type CustomField = { + id: string; + key: string; + displayName: string; + type: CustomFieldType; + target: CustomFieldTarget; + required: boolean; + userEditable: boolean; + defaultValue?: string; + validationRegex?: string; + validationErrorMessage?: string; +}; + +export function customFieldAppliesTo(field: CustomField, target: 'user' | 'group') { + return field.target === 'both' || field.target === target; +} diff --git a/frontend/src/lib/types/user-group.type.ts b/frontend/src/lib/types/user-group.type.ts index 2d977d73..596cdb72 100644 --- a/frontend/src/lib/types/user-group.type.ts +++ b/frontend/src/lib/types/user-group.type.ts @@ -1,4 +1,4 @@ -import type { CustomClaim } from './custom-claim.type'; +import type { CustomFieldValue } from './custom-field.type'; import type { OidcClientMetaData } from './oidc.type'; import type { User } from './user.type'; @@ -7,7 +7,7 @@ export type UserGroup = { friendlyName: string; name: string; createdAt: string; - customClaims: CustomClaim[]; + customFieldValues: CustomFieldValue[]; ldapId?: string; users: User[]; allowedOidcClients: OidcClientMetaData[]; @@ -17,4 +17,6 @@ export type UserGroupMinimal = Omit & userCount: number; }; -export type UserGroupCreate = Pick; +export type UserGroupCreate = Pick & { + customFieldValues?: CustomFieldValue[]; +}; diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts index d241728f..fc69c7de 100644 --- a/frontend/src/lib/types/user.type.ts +++ b/frontend/src/lib/types/user.type.ts @@ -1,5 +1,5 @@ import type { Locale } from '$lib/paraglide/runtime'; -import type { CustomClaim } from './custom-claim.type'; +import type { CustomFieldValue } from './custom-field.type'; import type { UserGroup } from './user-group.type'; export type User = { @@ -12,15 +12,20 @@ export type User = { displayName: string; isAdmin: boolean; userGroups: UserGroup[]; - customClaims: CustomClaim[]; + customFieldValues: CustomFieldValue[]; locale?: Locale; ldapId?: string; disabled?: boolean; }; -export type UserCreate = Omit; +export type UserCreate = Omit & { + customFieldValues?: CustomFieldValue[]; +}; -export type AccountUpdate = Omit; +export type AccountUpdate = Omit< + UserCreate, + 'isAdmin' | 'disabled' | 'emailVerified' +>; export type UserSignUp = Omit< UserCreate, diff --git a/frontend/src/routes/settings/account/account-form.svelte b/frontend/src/routes/settings/account/account-form.svelte index 9152cdf0..2ca154a1 100644 --- a/frontend/src/routes/settings/account/account-form.svelte +++ b/frontend/src/routes/settings/account/account-form.svelte @@ -1,4 +1,5 @@ + +{#snippet TypeCell({ item }: { item: CustomField })} + + {#if item.type === 'boolean'} + + {:else if item.type == 'number'} + + {:else} + + {/if} + +{/snippet} + +{#snippet TargetCell({ item }: { item: CustomField })} + {#if item.target === 'both'} + {m.users_and_groups()} + {:else if item.target === 'group'} + {m.groups()} + {:else} + {m.users()} + {/if} +{/snippet} + +{#snippet BoolCell({ item }: { item: CustomField })} + {#if item.userEditable} + + {:else} + + {/if} +{/snippet} + +
+ +
+ +
+
+ + diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-custom-fields-dialog.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-custom-fields-dialog.svelte new file mode 100644 index 00000000..2d81bd67 --- /dev/null +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-custom-fields-dialog.svelte @@ -0,0 +1,281 @@ + + + (show = open)}> + e.preventDefault()}> + + {existingCustomField ? m.edit() : m.add_custom_field()} + +
+
+ + + + {m.type()} + { + $inputs.type.value = value as any; + if (value !== 'string') { + $inputs.validationRegex.value = ''; + $inputs.validationErrorMessage.value = ''; + } + }} + > + + {fieldTypes.find((option) => option.value === $inputs.type.value)?.label} + + + {#each fieldTypes as option} + {option.label} + {/each} + + + + + {m.available_for()} + ($inputs.target.value = value as CustomFieldTarget)} + > + + {fieldTargets.find((option) => option.value === $inputs.target.value)?.label} + + + {#each fieldTargets as option} + {option.label} + {/each} + + + + + {#if $inputs.target.value != 'group'} + + {:else} + + {/if} + {#if $inputs.type.value === 'boolean'} + + {m.default_value()} + ($inputs.defaultValue.value = value)} + > + + {#if $inputs.defaultValue.value === 'true'} + {m.yes()} + {:else if $inputs.defaultValue.value === 'false'} + {m.no()} + {:else} + {m.no_default()} + {/if} + + + {m.no_default()} + {m.yes()} + {m.no()} + + + {#if $inputs.defaultValue.error} + {$inputs.defaultValue.error} + {/if} + + {:else} + + {/if} + {#if $inputs.type.value === 'string'} + + + {/if} +
+ + + + +
+
+
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte index 444ca875..4a054227 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte @@ -1,5 +1,4 @@ @@ -113,13 +109,6 @@ - - {m.custom_claims()} - - {m.user_creation_claims_description()} - - -
diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte index 280e0a37..34d359f3 100644 --- a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte @@ -1,11 +1,9 @@