mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-22 09:39:03 +02:00
feat!: replace custom claims with custom fields
This commit is contained in:
@@ -131,7 +131,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
|
|||||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
|
||||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||||
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||||
|
|||||||
@@ -13,25 +13,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type services struct {
|
type services struct {
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
appImagesService *service.AppImagesService
|
appImagesService *service.AppImagesService
|
||||||
emailService *service.EmailService
|
emailService *service.EmailService
|
||||||
geoLiteService *service.GeoLiteService
|
geoLiteService *service.GeoLiteService
|
||||||
auditLogService *service.AuditLogService
|
auditLogService *service.AuditLogService
|
||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
webauthnService *service.WebAuthnService
|
webauthnService *service.WebAuthnService
|
||||||
scimService *service.ScimService
|
scimService *service.ScimService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
customClaimService *service.CustomClaimService
|
customFieldValueService *service.CustomFieldValueService
|
||||||
oidcService *service.OidcService
|
oidcService *service.OidcService
|
||||||
userGroupService *service.UserGroupService
|
userGroupService *service.UserGroupService
|
||||||
ldapService *service.LdapService
|
ldapService *service.LdapService
|
||||||
apiKeyService *service.ApiKeyService
|
apiKeyService *service.ApiKeyService
|
||||||
versionService *service.VersionService
|
versionService *service.VersionService
|
||||||
fileStorage storage.FileStorage
|
fileStorage storage.FileStorage
|
||||||
appLockService *service.AppLockService
|
appLockService *service.AppLockService
|
||||||
userSignUpService *service.UserSignUpService
|
userSignUpService *service.UserSignUpService
|
||||||
oneTimeAccessService *service.OneTimeAccessService
|
oneTimeAccessService *service.OneTimeAccessService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initializes all services
|
// 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)
|
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)
|
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
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.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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
|
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.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||||
|
|
||||||
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
|
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
|
||||||
|
|||||||
@@ -171,23 +171,30 @@ type MissingSessionIdError struct{}
|
|||||||
func (e MissingSessionIdError) Error() string { return "Missing session id" }
|
func (e MissingSessionIdError) Error() string { return "Missing session id" }
|
||||||
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type ReservedClaimError struct {
|
type ReservedCustomFieldError struct {
|
||||||
Key string
|
Key string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e ReservedClaimError) Error() string {
|
func (e ReservedCustomFieldError) Error() string {
|
||||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
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
|
Key string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e DuplicateClaimError) Error() string {
|
func (e DuplicateCustomFieldError) Error() string {
|
||||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
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{}
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ type AppConfigUpdateDto struct {
|
|||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
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"`
|
AccentColor string `json:"accentColor"`
|
||||||
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||||
SmtpHost string `json:"smtpHost"`
|
SmtpHost string `json:"smtpHost"`
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type SignUpDto struct {
|
type SignUpDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,33 +7,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName *string `json:"lastName"`
|
LastName *string `json:"lastName"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
UserGroupIds []string `json:"userGroupIds"`
|
UserGroupIds []string `json:"userGroupIds"`
|
||||||
LdapID string `json:"-"`
|
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u UserCreateDto) Validate() error {
|
func (u UserCreateDto) Validate() error {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type UserGroupDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
Users []UserDto `json:"users"`
|
Users []UserDto `json:"users"`
|
||||||
@@ -22,7 +22,6 @@ type UserGroupMinimalDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
|
||||||
UserCount int64 `json:"userCount"`
|
UserCount int64 `json:"userCount"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
@@ -33,9 +32,10 @@ type UserGroupUpdateAllowedOidcClientsDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||||
LdapID string `json:"-"`
|
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g UserGroupCreateDto) Validate() error {
|
func (g UserGroupCreateDto) Validate() error {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
@@ -33,6 +35,12 @@ func init() {
|
|||||||
"client_id": func(fl validator.FieldLevel) bool {
|
"client_id": func(fl validator.FieldLevel) bool {
|
||||||
return ValidateClientID(fl.Field().String())
|
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": func(fl validator.FieldLevel) bool {
|
||||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||||
if !ok {
|
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
|
// ValidateUsername validates username inputs
|
||||||
func ValidateUsername(username string) bool {
|
func ValidateUsername(username string) bool {
|
||||||
return validateUsernameRegex.MatchString(username)
|
return validateUsernameRegex.MatchString(username)
|
||||||
@@ -69,6 +87,20 @@ func ValidateClientID(clientID string) bool {
|
|||||||
return validateClientIDRegex.MatchString(clientID)
|
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
|
// ValidateCallbackURL validates the input callback URL
|
||||||
func ValidateCallbackURL(str string) bool {
|
func ValidateCallbackURL(str string) bool {
|
||||||
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
|
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
|
||||||
|
|||||||
@@ -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) {
|
func TestValidateResponseMode(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
|||||||
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
||||||
require.NoError(t, err)
|
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)
|
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type AppConfig struct {
|
|||||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
CustomFields AppConfigVariable `key:"customFields,public"` // Public
|
||||||
// Internal
|
// Internal
|
||||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||||
// Email
|
// Email
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type CustomClaim struct {
|
|
||||||
Base
|
|
||||||
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
|
|
||||||
UserID *string
|
|
||||||
UserGroupID *string
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type CustomFieldValue struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
CustomFieldID string
|
||||||
|
Value string
|
||||||
|
|
||||||
|
UserID *string
|
||||||
|
UserGroupID *string
|
||||||
|
}
|
||||||
@@ -26,9 +26,9 @@ type User struct {
|
|||||||
Disabled bool `sortable:"true" filterable:"true"`
|
Disabled bool `sortable:"true" filterable:"true"`
|
||||||
UpdatedAt *datatype.DateTime
|
UpdatedAt *datatype.DateTime
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomFieldValues []CustomFieldValue
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
Credentials []WebauthnCredential
|
Credentials []WebauthnCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type UserGroup struct {
|
|||||||
LdapID *string
|
LdapID *string
|
||||||
UpdatedAt *datatype.DateTime
|
UpdatedAt *datatype.DateTime
|
||||||
Users []User `gorm:"many2many:user_groups_users;"`
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
CustomClaims []CustomClaim
|
CustomFieldValues []CustomFieldValue
|
||||||
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -67,7 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
CustomFields: model.AppConfigVariable{Value: "[]"},
|
||||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
InstanceID: model.AppConfigVariable{Value: ""},
|
InstanceID: model.AppConfigVariable{Value: ""},
|
||||||
@@ -158,6 +159,87 @@ func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx
|
|||||||
return nil
|
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) {
|
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||||
if common.EnvConfig.UiConfigDisabled {
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
return nil, &common.UiConfigDisabledError{}
|
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)
|
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()
|
defaultCfg := s.getDefaultDbConfig()
|
||||||
|
|
||||||
// Iterate through all the fields to update
|
// Iterate through all the fields to update
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
// Verify database was updated
|
// Verify database was updated
|
||||||
var count int64
|
var count int64
|
||||||
db.Model(&model.AppConfigVariable{}).Count(&count)
|
db.Model(&model.AppConfigVariable{}).Count(&count)
|
||||||
require.Equal(t, int64(3), count)
|
require.GreaterOrEqual(t, count, int64(3))
|
||||||
|
|
||||||
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
||||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||||
@@ -470,4 +471,98 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
var uiConfigDisabledErr *common.UiConfigDisabledError
|
var uiConfigDisabledErr *common.UiConfigDisabledError
|
||||||
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -563,6 +565,56 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
|||||||
return err
|
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
|
// Reload the app config from the database after resetting the values
|
||||||
err = s.appConfigService.LoadDbConfig(ctx)
|
err = s.appConfigService.LoadDbConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
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()
|
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
|
// Query LDAP for all groups we want to manage
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
@@ -225,6 +286,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
|||||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||||
dbConfig.LdapAttributeGroupMember.Value,
|
dbConfig.LdapAttributeGroupMember.Value,
|
||||||
}
|
}
|
||||||
|
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
|
||||||
|
|
||||||
searchReq := ldap.NewSearchRequest(
|
searchReq := ldap.NewSearchRequest(
|
||||||
dbConfig.LdapBase.Value,
|
dbConfig.LdapBase.Value,
|
||||||
@@ -267,9 +329,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncGroup := dto.UserGroupCreateDto{
|
syncGroup := dto.UserGroupCreateDto{
|
||||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
LdapID: ldapID,
|
LdapID: ldapID,
|
||||||
|
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
|
||||||
}
|
}
|
||||||
dto.Normalize(&syncGroup)
|
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) {
|
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()
|
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
|
// Query LDAP for all users we want to manage
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
@@ -304,6 +371,7 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
|||||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||||
dbConfig.LdapAttributeUserDisplayName.Value,
|
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||||
}
|
}
|
||||||
|
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
|
||||||
|
|
||||||
// Filters must start and finish with ()!
|
// Filters must start and finish with ()!
|
||||||
searchReq := ldap.NewSearchRequest(
|
searchReq := ldap.NewSearchRequest(
|
||||||
@@ -342,12 +410,13 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
|||||||
ldapUserIDs[ldapID] = struct{}{}
|
ldapUserIDs[ldapID] = struct{}{}
|
||||||
|
|
||||||
newUser := dto.UserCreateDto{
|
newUser := dto.UserCreateDto{
|
||||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||||
EmailVerified: true,
|
EmailVerified: true,
|
||||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||||
|
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
|
||||||
// Admin status is computed after groups are loaded so it can use the
|
// Admin status is computed after groups are loaded so it can use the
|
||||||
// configured group member attribute instead of a hard-coded memberOf.
|
// configured group member attribute instead of a hard-coded memberOf.
|
||||||
IsAdmin: false,
|
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 {
|
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
|
// Load the current LDAP-managed state from the database
|
||||||
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,28 +522,38 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
|
|||||||
}
|
}
|
||||||
|
|
||||||
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
||||||
|
var groupID string
|
||||||
if databaseGroup.ID == "" {
|
if databaseGroup.ID == "" {
|
||||||
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
||||||
}
|
}
|
||||||
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
||||||
|
groupID = newGroup.ID
|
||||||
|
|
||||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
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 len(customFields) > 0 {
|
||||||
if err != nil {
|
_, err = s.groupService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserGroupID, groupID, desiredGroup.input.CustomFieldValues, customFields, tx)
|
||||||
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("failed to sync custom fields for 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +584,10 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
|
|||||||
//nolint:gocognit
|
//nolint:gocognit
|
||||||
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
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()
|
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
|
// Load the current LDAP-managed state from the database
|
||||||
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
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 != "" {
|
if desiredUser.picture != "" {
|
||||||
savePictures = append(savePictures, savePicture{
|
savePictures = append(savePictures, savePicture{
|
||||||
userID: userID,
|
userID: userID,
|
||||||
|
|||||||
@@ -141,6 +141,51 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
|
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.
|
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
|
||||||
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||||
appCfg := defaultTestLDAPAppConfig()
|
appCfg := defaultTestLDAPAppConfig()
|
||||||
@@ -318,14 +363,15 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
|
|||||||
|
|
||||||
appConfig := NewTestAppConfigService(appConfigModel)
|
appConfig := NewTestAppConfigService(appConfigModel)
|
||||||
|
|
||||||
groupService := NewUserGroupService(db, appConfig, nil)
|
customFieldValueService := NewCustomFieldValueService(db, appConfig)
|
||||||
|
groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil)
|
||||||
userService := NewUserService(
|
userService := NewUserService(
|
||||||
db,
|
db,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
appConfig,
|
appConfig,
|
||||||
NewCustomClaimService(db),
|
customFieldValueService,
|
||||||
NewAppImagesService(map[string]string{}, fileStorage),
|
NewAppImagesService(map[string]string{}, fileStorage),
|
||||||
nil,
|
nil,
|
||||||
fileStorage,
|
fileStorage,
|
||||||
@@ -405,6 +451,15 @@ func usernames(users []model.User) []string {
|
|||||||
return result
|
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) {
|
func TestGetDNProperty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -51,13 +50,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OidcService struct {
|
type OidcService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
customClaimService *CustomClaimService
|
customFieldValueService *CustomFieldValueService
|
||||||
webAuthnService *WebAuthnService
|
webAuthnService *WebAuthnService
|
||||||
scimService *ScimService
|
scimService *ScimService
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
jwkCache *jwk.Cache
|
jwkCache *jwk.Cache
|
||||||
@@ -70,22 +69,22 @@ func NewOidcService(
|
|||||||
jwtService *JwtService,
|
jwtService *JwtService,
|
||||||
appConfigService *AppConfigService,
|
appConfigService *AppConfigService,
|
||||||
auditLogService *AuditLogService,
|
auditLogService *AuditLogService,
|
||||||
customClaimService *CustomClaimService,
|
customFieldValueService *CustomFieldValueService,
|
||||||
webAuthnService *WebAuthnService,
|
webAuthnService *WebAuthnService,
|
||||||
scimService *ScimService,
|
scimService *ScimService,
|
||||||
httpClient *http.Client,
|
httpClient *http.Client,
|
||||||
fileStorage storage.FileStorage,
|
fileStorage storage.FileStorage,
|
||||||
) (s *OidcService, err error) {
|
) (s *OidcService, err error) {
|
||||||
s = &OidcService{
|
s = &OidcService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
customClaimService: customClaimService,
|
customFieldValueService: customFieldValueService,
|
||||||
webAuthnService: webAuthnService,
|
webAuthnService: webAuthnService,
|
||||||
scimService: scimService,
|
scimService: scimService,
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
fileStorage: fileStorage,
|
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
|
// 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") {
|
if slices.Contains(scopes, "profile") {
|
||||||
// Add custom claims
|
// We need to fetch the user and group fields first because we need the key of the custom key later
|
||||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, customClaim := range customClaims {
|
for _, customFieldValue := range customFieldValues {
|
||||||
// The value of the custom claim can be a JSON object or a string
|
var customField *dto.CustomFieldDto
|
||||||
var jsonValue any
|
var claimKey string
|
||||||
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
|
if customFieldValue.UserID != nil {
|
||||||
if err == nil {
|
if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok {
|
||||||
// It's JSON, so we store it as an object
|
customField = &field
|
||||||
claims[customClaim.Key] = jsonValue
|
claimKey = field.Key
|
||||||
} else {
|
}
|
||||||
// Marshaling failed, so we store it as a string
|
} else if customFieldValue.UserGroupID != nil {
|
||||||
claims[customClaim.Key] = customClaim.Value
|
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
|
// Add profile claims
|
||||||
|
|||||||
@@ -15,19 +15,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserGroupService struct {
|
type UserGroupService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
scimService *ScimService
|
scimService *ScimService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
|
customFieldValueService *CustomFieldValueService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService {
|
||||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
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) {
|
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).
|
WithContext(ctx).
|
||||||
Preload("CustomClaims").
|
|
||||||
Model(&model.UserGroup{})
|
Model(&model.UserGroup{})
|
||||||
|
|
||||||
if name != "" {
|
if name != "" {
|
||||||
@@ -43,6 +48,17 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
|
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
|
return groups, response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +70,19 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
|
|||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Preload("CustomClaims").
|
|
||||||
Preload("Users").
|
Preload("Users").
|
||||||
Preload("AllowedOidcClients").
|
Preload("AllowedOidcClients").
|
||||||
First(&group).
|
First(&group).
|
||||||
Error
|
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
|
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) {
|
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) {
|
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
|
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 {
|
if s.scimService != nil {
|
||||||
s.scimService.ScheduleSync()
|
s.scimService.ScheduleSync()
|
||||||
}
|
}
|
||||||
@@ -181,6 +226,12 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
|||||||
return model.UserGroup{}, err
|
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 {
|
if s.scimService != nil {
|
||||||
s.scimService.ScheduleSync()
|
s.scimService.ScheduleSync()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,37 +27,41 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
customClaimService *CustomClaimService
|
customFieldValueService *CustomFieldValueService
|
||||||
appImagesService *AppImagesService
|
appImagesService *AppImagesService
|
||||||
scimService *ScimService
|
scimService *ScimService
|
||||||
fileStorage storage.FileStorage
|
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{
|
return &UserService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
customClaimService: customClaimService,
|
customFieldValueService: customFieldValueService,
|
||||||
appImagesService: appImagesService,
|
appImagesService: appImagesService,
|
||||||
scimService: scimService,
|
scimService: scimService,
|
||||||
fileStorage: fileStorage,
|
fileStorage: fileStorage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
|
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
|
var users []model.User
|
||||||
query := s.db.WithContext(ctx).
|
query := tx.WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
Preload("UserGroups").
|
Preload("UserGroups")
|
||||||
Preload("CustomClaims")
|
|
||||||
|
|
||||||
if searchTerm != "" {
|
if searchTerm != "" {
|
||||||
searchPattern := "%" + searchTerm + "%"
|
searchPattern := "%" + searchTerm + "%"
|
||||||
@@ -67,6 +71,16 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
|
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
|
return users, pagination, err
|
||||||
}
|
}
|
||||||
@@ -80,10 +94,17 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
|
|||||||
err := tx.
|
err := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Preload("UserGroups").
|
Preload("UserGroups").
|
||||||
Preload("CustomClaims").
|
|
||||||
Where("id = ?", userID).
|
Where("id = ?", userID).
|
||||||
First(&user).
|
First(&user).
|
||||||
Error
|
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
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,15 +322,17 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
return model.User{}, err
|
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 !isLdapSync {
|
||||||
if len(input.UserGroupIds) == 0 {
|
if len(input.UserGroupIds) == 0 {
|
||||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
||||||
return model.User{}, err
|
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
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,27 +376,6 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
|||||||
return nil
|
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) {
|
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -463,6 +465,15 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
return user, err
|
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 {
|
if s.scimService != nil {
|
||||||
s.scimService.ScheduleSync()
|
s.scimService.ScheduleSync()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
userToCreate := dto.UserCreateDto{
|
||||||
Username: signupData.Username,
|
Username: signupData.Username,
|
||||||
Email: signupData.Email,
|
Email: signupData.Email,
|
||||||
FirstName: signupData.FirstName,
|
FirstName: signupData.FirstName,
|
||||||
LastName: signupData.LastName,
|
LastName: signupData.LastName,
|
||||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||||
UserGroupIds: userGroupIDs,
|
UserGroupIds: userGroupIDs,
|
||||||
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
|
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
|
||||||
|
CustomFieldValues: customFieldValues,
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
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{}
|
return model.User{}, "", &common.SetupNotAvailableError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
userToCreate := dto.UserCreateDto{
|
userToCreate := dto.UserCreateDto{
|
||||||
FirstName: signUpData.FirstName,
|
FirstName: signUpData.FirstName,
|
||||||
LastName: signUpData.LastName,
|
LastName: signUpData.LastName,
|
||||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||||
Username: signUpData.Username,
|
Username: signUpData.Username,
|
||||||
Email: signUpData.Email,
|
Email: signUpData.Email,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
|
CustomFieldValues: customFieldValues,
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
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
|
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) {
|
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
|
||||||
return s.isInitialAdminSetupCompleted(ctx, s.db)
|
return s.isInitialAdminSetupCompleted(ctx, s.db)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -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';
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
+30
-19
@@ -5,9 +5,6 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"docs": "Docs",
|
"docs": "Docs",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"value": "Value",
|
|
||||||
"remove_custom_claim": "Remove custom claim",
|
|
||||||
"add_custom_claim": "Add custom claim",
|
|
||||||
"add_another": "Add another",
|
"add_another": "Add another",
|
||||||
"select_a_date": "Select a date",
|
"select_a_date": "Select a date",
|
||||||
"select_file": "Select File",
|
"select_file": "Select File",
|
||||||
@@ -35,7 +32,6 @@
|
|||||||
"one_week": "1 week",
|
"one_week": "1 week",
|
||||||
"one_month": "1 month",
|
"one_month": "1 month",
|
||||||
"expiration": "Expiration",
|
"expiration": "Expiration",
|
||||||
"generate_code": "Generate Code",
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"browser_unsupported": "Browser unsupported",
|
"browser_unsupported": "Browser unsupported",
|
||||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||||
"authenticate": "Authenticate",
|
"authenticate": "Authenticate",
|
||||||
"please_try_again": "Please try again.",
|
"please_try_again": "Please try again.",
|
||||||
"continue": "Continue",
|
|
||||||
"alternative_sign_in": "Alternative Sign In",
|
"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.",
|
"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?",
|
"use_your_passkey_instead": "Use your passkey instead?",
|
||||||
@@ -169,7 +164,6 @@
|
|||||||
"ldap": "LDAP",
|
"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.",
|
"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",
|
"images": "Images",
|
||||||
"update": "Update",
|
|
||||||
"email_configuration_updated_successfully": "Email configuration updated successfully",
|
"email_configuration_updated_successfully": "Email configuration updated successfully",
|
||||||
"save_changes_question": "Save changes?",
|
"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?",
|
"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",
|
"edit": "Edit",
|
||||||
"user_groups_updated_successfully": "User groups updated successfully",
|
"user_groups_updated_successfully": "User groups updated successfully",
|
||||||
"user_updated_successfully": "User updated successfully",
|
"user_updated_successfully": "User updated successfully",
|
||||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
||||||
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
|
"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",
|
"user_group_created_successfully": "User group created successfully",
|
||||||
"create_user_group": "Create User Group",
|
"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.",
|
"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",
|
"users_updated_successfully": "Users updated successfully",
|
||||||
"user_group_details_name": "User Group Details {name}",
|
"user_group_details_name": "User Group Details {name}",
|
||||||
"assign_users_to_this_group": "Assign users to this group.",
|
"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",
|
"oidc_client_created_successfully": "OIDC client created successfully",
|
||||||
"create_oidc_client": "Create OIDC Client",
|
"create_oidc_client": "Create OIDC Client",
|
||||||
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
||||||
@@ -289,9 +279,7 @@
|
|||||||
"requires_reauthentication": "Requires Re-Authentication",
|
"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",
|
"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",
|
"name_logo": "{name} logo",
|
||||||
"change_logo": "Change Logo",
|
|
||||||
"upload_logo": "Upload 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?",
|
"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",
|
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
|
||||||
"authorization_url": "Authorization URL",
|
"authorization_url": "Authorization URL",
|
||||||
@@ -373,7 +361,6 @@
|
|||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
"unrestricted": "Unrestricted",
|
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Show Advanced Options",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Hide Advanced Options",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "OIDC Data Preview",
|
||||||
@@ -385,9 +372,7 @@
|
|||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Access Token Payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"no_preview_data_available": "No preview data available",
|
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Copy All",
|
||||||
"preview": "Preview",
|
|
||||||
"preview_for_user": "Preview for {name}",
|
"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",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
@@ -409,11 +394,9 @@
|
|||||||
"user_creation": "User Creation",
|
"user_creation": "User Creation",
|
||||||
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
|
"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_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.",
|
"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_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",
|
"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",
|
"go_to_login": "Go to login",
|
||||||
"signup_to_appname": "Sign Up to {appName}",
|
"signup_to_appname": "Sign Up to {appName}",
|
||||||
"create_your_account_to_get_started": "Create your account to get started.",
|
"create_your_account_to_get_started": "Create your account to get started.",
|
||||||
@@ -436,7 +419,6 @@
|
|||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"loading": "Loading",
|
|
||||||
"delete_signup_token": "Delete Signup Token",
|
"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.",
|
"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",
|
"signup_with_token": "Signup with token",
|
||||||
@@ -526,5 +508,34 @@
|
|||||||
"email_verification_sent": "Verification email sent successfully.",
|
"email_verification_sent": "Verification email sent successfully.",
|
||||||
"emails_verified_by_default": "Emails verified by default",
|
"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.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
|
noHeaderMargin = false,
|
||||||
forcedExpanded,
|
forcedExpanded,
|
||||||
button,
|
button,
|
||||||
icon,
|
icon,
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
noHeaderMargin?: boolean;
|
||||||
forcedExpanded?: boolean;
|
forcedExpanded?: boolean;
|
||||||
icon?: typeof IconType;
|
icon?: typeof IconType;
|
||||||
button?: Snippet;
|
button?: Snippet;
|
||||||
@@ -60,7 +62,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root
|
||||||
|
class={{
|
||||||
|
'gap-0': noHeaderMargin
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
|
<Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
|
||||||
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
|
||||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
|
||||||
import { onMount, type Snippet } from 'svelte';
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
import AutoCompleteInput from './auto-complete-input.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let {
|
|
||||||
customClaims = $bindable(),
|
|
||||||
error = $bindable(null),
|
|
||||||
...restProps
|
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
|
||||||
customClaims: CustomClaim[];
|
|
||||||
error?: string | null;
|
|
||||||
children?: Snippet;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const limit = 20;
|
|
||||||
|
|
||||||
const customClaimService = new CustomClaimService();
|
|
||||||
|
|
||||||
let suggestions: string[] = $state([]);
|
|
||||||
let filteredSuggestions: string[] = $derived(
|
|
||||||
suggestions.filter(
|
|
||||||
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
customClaimService.getSuggestions().then((data) => (suggestions = data));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div {...restProps}>
|
|
||||||
<FormInput>
|
|
||||||
<div class="flex flex-col gap-y-2">
|
|
||||||
{#each customClaims as _, i}
|
|
||||||
<div class="flex gap-x-2">
|
|
||||||
<AutoCompleteInput
|
|
||||||
placeholder={m.key()}
|
|
||||||
suggestions={filteredSuggestions}
|
|
||||||
bind:value={customClaims[i].key}
|
|
||||||
/>
|
|
||||||
<Input placeholder={m.value()} bind:value={customClaims[i].value} />
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
aria-label={m.remove_custom_claim()}
|
|
||||||
onclick={() => (customClaims = customClaims.filter((_, index) => index !== i))}
|
|
||||||
>
|
|
||||||
<LucideMinus class="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</FormInput>
|
|
||||||
{#if error}
|
|
||||||
<p class="text-destructive mt-1 text-xs">{error}</p>
|
|
||||||
{/if}
|
|
||||||
{#if customClaims.length < limit}
|
|
||||||
<Button
|
|
||||||
class="mt-2"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (customClaims = [...customClaims, { key: '', value: '' }])}
|
|
||||||
>
|
|
||||||
<LucidePlus class="mr-1 size-4" />
|
|
||||||
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Field from '$lib/components/ui/field';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { CustomField, CustomFieldValue } from '$lib/types/custom-field.type';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
customFieldValues = $bindable(),
|
||||||
|
customFields = []
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
customFieldValues: CustomFieldValue[];
|
||||||
|
customFields?: CustomField[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
function getValue(field: CustomField) {
|
||||||
|
const customFieldValue = customFieldValues.find(
|
||||||
|
(customFieldValue) => customFieldValue.customFieldId === field.id
|
||||||
|
);
|
||||||
|
if (customFieldValue) return customFieldValue.value;
|
||||||
|
|
||||||
|
return field.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(field: CustomField, value: string) {
|
||||||
|
errors = { ...errors, [field.key]: '' };
|
||||||
|
const nextCustomFieldValues = customFieldValues.filter(
|
||||||
|
(customFieldValue) => customFieldValue.customFieldId !== field.id
|
||||||
|
);
|
||||||
|
if (field.type !== 'boolean' && !field.required && value === '') {
|
||||||
|
customFieldValues = nextCustomFieldValues;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customFieldValues = [...nextCustomFieldValues, { customFieldId: field.id, value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomFieldValues() {
|
||||||
|
const normalizedCustomFieldValues: CustomFieldValue[] = [];
|
||||||
|
for (const field of customFields) {
|
||||||
|
const value = field.type === 'boolean' ? getValue(field) || 'false' : getValue(field);
|
||||||
|
if (field.type !== 'boolean' && !field.required && value === '') continue;
|
||||||
|
normalizedCustomFieldValues.push({ customFieldId: field.id, value });
|
||||||
|
}
|
||||||
|
customFieldValues = normalizedCustomFieldValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate() {
|
||||||
|
normalizeCustomFieldValues();
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const field of customFields) {
|
||||||
|
const value = getValue(field);
|
||||||
|
if (field.required && field.type !== 'boolean' && value === '') {
|
||||||
|
nextErrors[field.key] = m.field_is_required();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.type === 'number' && value !== '' && Number.isNaN(Number(value))) {
|
||||||
|
nextErrors[field.key] = m.must_be_a_number();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.type === 'string' && field.validationRegex && value !== '') {
|
||||||
|
let regex: RegExp;
|
||||||
|
try {
|
||||||
|
regex = new RegExp(field.validationRegex);
|
||||||
|
} catch {
|
||||||
|
nextErrors[field.key] = m.invalid_regex();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!regex.test(value)) {
|
||||||
|
nextErrors[field.key] =
|
||||||
|
field.validationErrorMessage || m.value_does_not_match_required_format();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = nextErrors;
|
||||||
|
return Object.keys(nextErrors).length === 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each customFields as field (field.key)}
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label required={field.required} for={`custom-field-${field.key}`}>
|
||||||
|
{field.displayName || field.key}
|
||||||
|
</Field.Label>
|
||||||
|
{#if field.type === 'boolean'}
|
||||||
|
<div class="flex h-9 items-center">
|
||||||
|
<Switch
|
||||||
|
id={`custom-field-${field.key}`}
|
||||||
|
checked={getValue(field) === 'true'}
|
||||||
|
onCheckedChange={(checked) => setValue(field, checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
id={`custom-field-${field.key}`}
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={getValue(field)}
|
||||||
|
aria-invalid={!!errors[field.key]}
|
||||||
|
oninput={(e) => setValue(field, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if errors[field.key]}
|
||||||
|
<Field.Error class="text-start">{errors[field.key]}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
{/each}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
|
import { cn } from '$lib/utils/style';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id,
|
id,
|
||||||
|
class: className,
|
||||||
checked = $bindable(),
|
checked = $bindable(),
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
onCheckedChange
|
onCheckedChange
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
|
class?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="items-top flex space-x-2">
|
<div class={cn("items-top flex space-x-2", className)}>
|
||||||
<Switch
|
<Switch
|
||||||
{id}
|
{id}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
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 type { UserSignUp } from '$lib/types/user.type';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
@@ -35,16 +37,23 @@
|
|||||||
|
|
||||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
|
||||||
|
|
||||||
let userData: UserSignUp | null = $state(null);
|
let customFieldValues = $state<CustomFieldValue[]>([]);
|
||||||
|
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
|
||||||
|
let customFields = $derived(
|
||||||
|
$appConfigStore.customFields.filter(
|
||||||
|
(field) => customFieldAppliesTo(field, 'user') && (field.required && field.userEditable)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
if (customFieldValuesInputRef && !customFieldValuesInputRef.validate()) return;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
const result = await tryCatch(callback(data));
|
const result = await tryCatch(callback({ ...data, customFieldValues }));
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
userData = data;
|
userData = { ...data, customFieldValues };
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +67,11 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
||||||
|
<CustomFieldValuesInput
|
||||||
|
bind:this={customFieldValuesInputRef}
|
||||||
|
bind:customFieldValues
|
||||||
|
{customFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
selectedIds = $bindable(),
|
selectedIds = $bindable(),
|
||||||
withoutSearch = false,
|
withoutSearch = false,
|
||||||
selectionDisabled = false,
|
selectionDisabled = false,
|
||||||
|
paginationDisabled = false,
|
||||||
rowSelectionDisabled,
|
rowSelectionDisabled,
|
||||||
fetchCallback,
|
fetchCallback,
|
||||||
defaultSort,
|
defaultSort,
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
selectedIds?: string[];
|
selectedIds?: string[];
|
||||||
withoutSearch?: boolean;
|
withoutSearch?: boolean;
|
||||||
selectionDisabled?: boolean;
|
selectionDisabled?: boolean;
|
||||||
|
paginationDisabled?: boolean;
|
||||||
rowSelectionDisabled?: (item: T) => boolean;
|
rowSelectionDisabled?: (item: T) => boolean;
|
||||||
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||||
defaultSort?: SortRequest;
|
defaultSort?: SortRequest;
|
||||||
@@ -178,8 +180,10 @@
|
|||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
items = await fetchCallback(requestOptions);
|
items = await fetchCallback(requestOptions);
|
||||||
changePageState(items.pagination.currentPage);
|
if (!paginationDisabled) {
|
||||||
updateListLength(items.pagination.totalItems);
|
changePageState(items.pagination.currentPage);
|
||||||
|
updateListLength(items.pagination.totalItems);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -331,51 +335,52 @@
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !paginationDisabled}
|
||||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
value={items?.pagination.itemsPerPage.toString()}
|
value={items?.pagination.itemsPerPage.toString()}
|
||||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-20">
|
||||||
|
{items?.pagination.itemsPerPage}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each availablePageSizes as size}
|
||||||
|
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={items?.pagination.totalItems || 0}
|
||||||
|
perPage={items?.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={items?.pagination.currentPage}
|
||||||
>
|
>
|
||||||
<Select.Trigger class="w-20">
|
{#snippet children({ pages })}
|
||||||
{items?.pagination.itemsPerPage}
|
<Pagination.Content class="flex justify-end">
|
||||||
</Select.Trigger>
|
<Pagination.Item>
|
||||||
<Select.Content>
|
<Pagination.PrevButton />
|
||||||
{#each availablePageSizes as size}
|
</Pagination.Item>
|
||||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
{#each pages as page (page.key)}
|
||||||
{/each}
|
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||||
</Select.Content>
|
<Pagination.Item>
|
||||||
</Select.Root>
|
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
</div>
|
</div>
|
||||||
<Pagination.Root
|
{/if}
|
||||||
class="mx-0 w-auto"
|
|
||||||
count={items?.pagination.totalItems || 0}
|
|
||||||
perPage={items?.pagination.itemsPerPage}
|
|
||||||
{onPageChange}
|
|
||||||
page={items?.pagination.currentPage}
|
|
||||||
>
|
|
||||||
{#snippet children({ pages })}
|
|
||||||
<Pagination.Content class="flex justify-end">
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.PrevButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
{#each pages as page (page.key)}
|
|
||||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
{/snippet}
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Tabs as TabsPrimitive } from 'bits-ui';
|
import { page } from '$app/state';
|
||||||
import { cn } from '$lib/utils/style.js';
|
import { cn } from '$lib/utils/style.js';
|
||||||
|
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
|
useHash = false,
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: TabsPrimitive.RootProps = $props();
|
}: TabsPrimitive.RootProps & {
|
||||||
|
useHash?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (useHash && page.url.hash) {
|
||||||
|
value = page.url.hash.substring(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTabChange(newValue: string) {
|
||||||
|
if (useHash && page.url.hash !== newValue) {
|
||||||
|
window.location.hash = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
bind:ref
|
bind:ref
|
||||||
bind:value
|
bind:value
|
||||||
|
onValueChange={onTabChange}
|
||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
class={cn('flex flex-col gap-2', className)}
|
class={cn('flex flex-col gap-2', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
|
||||||
import APIService from './api-service';
|
|
||||||
|
|
||||||
export default class CustomClaimService extends APIService {
|
|
||||||
getSuggestions = async () => {
|
|
||||||
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[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CustomClaim } from './custom-claim.type';
|
import type { CustomFieldValue, CustomField } from './custom-field.type';
|
||||||
|
|
||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -13,6 +13,7 @@ export type AppConfig = {
|
|||||||
uiConfigDisabled: boolean;
|
uiConfigDisabled: boolean;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
requireUserEmail: boolean;
|
requireUserEmail: boolean;
|
||||||
|
customFields: CustomField[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AllAppConfig = AppConfig & {
|
export type AllAppConfig = AppConfig & {
|
||||||
@@ -20,7 +21,6 @@ export type AllAppConfig = AppConfig & {
|
|||||||
sessionDuration: number;
|
sessionDuration: number;
|
||||||
emailsVerified: boolean;
|
emailsVerified: boolean;
|
||||||
signupDefaultUserGroupIDs: string[];
|
signupDefaultUserGroupIDs: string[];
|
||||||
signupDefaultCustomClaims: CustomClaim[];
|
|
||||||
// Email
|
// Email
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpPort: string;
|
smtpPort: string;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export type CustomClaim = {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { OidcClientMetaData } from './oidc.type';
|
||||||
import type { User } from './user.type';
|
import type { User } from './user.type';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export type UserGroup = {
|
|||||||
friendlyName: string;
|
friendlyName: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
customClaims: CustomClaim[];
|
customFieldValues: CustomFieldValue[];
|
||||||
ldapId?: string;
|
ldapId?: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
allowedOidcClients: OidcClientMetaData[];
|
allowedOidcClients: OidcClientMetaData[];
|
||||||
@@ -17,4 +17,6 @@ export type UserGroupMinimal = Omit<UserGroup, 'users' | 'allowedOidcClients'> &
|
|||||||
userCount: number;
|
userCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'>;
|
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'> & {
|
||||||
|
customFieldValues?: CustomFieldValue[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Locale } from '$lib/paraglide/runtime';
|
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';
|
import type { UserGroup } from './user-group.type';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
@@ -12,15 +12,20 @@ export type User = {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userGroups: UserGroup[];
|
userGroups: UserGroup[];
|
||||||
customClaims: CustomClaim[];
|
customFieldValues: CustomFieldValue[];
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
ldapId?: string;
|
ldapId?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
export type UserCreate = Omit<User, 'id' | 'customFieldValues' | 'ldapId' | 'userGroups'> & {
|
||||||
|
customFieldValues?: CustomFieldValue[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
|
export type AccountUpdate = Omit<
|
||||||
|
UserCreate,
|
||||||
|
'isAdmin' | 'disabled' | 'emailVerified'
|
||||||
|
>;
|
||||||
|
|
||||||
export type UserSignUp = Omit<
|
export type UserSignUp = Omit<
|
||||||
UserCreate,
|
UserCreate,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
|
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
|
||||||
import type { AccountUpdate } from '$lib/types/user.type';
|
import type { AccountUpdate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
@@ -31,6 +33,13 @@
|
|||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let hasManualDisplayNameEdit = $state(!!account.displayName);
|
let hasManualDisplayNameEdit = $state(!!account.displayName);
|
||||||
|
let customFieldValues = $state(account.customFieldValues || []);
|
||||||
|
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
|
||||||
|
let customFields = $derived(
|
||||||
|
$appConfigStore.customFields.filter(
|
||||||
|
(field) => customFieldAppliesTo(field, 'user') && field.userEditable
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
@@ -59,8 +68,13 @@
|
|||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
if (!customFieldValuesInputRef?.validate()) return;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback(data);
|
await callback({
|
||||||
|
...data,
|
||||||
|
customFieldValues
|
||||||
|
});
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +114,15 @@
|
|||||||
bind:input={$inputs.displayName}
|
bind:input={$inputs.displayName}
|
||||||
onInput={() => (hasManualDisplayNameEdit = true)}
|
onInput={() => (hasManualDisplayNameEdit = true)}
|
||||||
/>
|
/>
|
||||||
|
<CustomFieldValuesInput
|
||||||
|
bind:this={customFieldValuesInputRef}
|
||||||
|
bind:customFieldValues
|
||||||
|
{customFields}
|
||||||
|
/>
|
||||||
</Field.Group>
|
</Field.Group>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
|
||||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
|
import * as Tabs from '$lib/components/ui/tabs';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import AppConfigService from '$lib/services/app-config-service';
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration.type';
|
import type { AllAppConfig } from '$lib/types/application-configuration.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import {
|
import {
|
||||||
|
ListChecks,
|
||||||
LucideImage,
|
LucideImage,
|
||||||
LucideInfo,
|
LucideInfo,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
Users
|
Users
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import CustomFieldsTable from './custom-fields-table.svelte';
|
||||||
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
|
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
|
||||||
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
|
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
|
||||||
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
|
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
|
||||||
@@ -101,57 +104,67 @@
|
|||||||
</Alert.Description>
|
</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
|
||||||
<CollapsibleCard
|
|
||||||
id="application-configuration-general"
|
|
||||||
icon={SlidersHorizontal}
|
|
||||||
title={m.general()}
|
|
||||||
defaultExpanded
|
|
||||||
>
|
|
||||||
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
|
||||||
</CollapsibleCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Tabs.Root value="general" useHash>
|
||||||
<CollapsibleCard
|
<Tabs.List class="mb-3">
|
||||||
id="application-configuration-signup-defaults"
|
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
|
||||||
icon={Users}
|
<Tabs.Trigger value="user-and-groups">{m.users_and_groups()}</Tabs.Trigger>
|
||||||
title={m.user_creation()}
|
<Tabs.Trigger value="email">{m.email()}</Tabs.Trigger>
|
||||||
description={m.configure_user_creation()}
|
</Tabs.List>
|
||||||
>
|
<Tabs.Content value="general" class="flex flex-col gap-5">
|
||||||
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
|
<CollapsibleCard
|
||||||
</CollapsibleCard>
|
id="application-configuration-general"
|
||||||
</div>
|
icon={SlidersHorizontal}
|
||||||
|
title={m.general()}
|
||||||
|
defaultExpanded
|
||||||
|
>
|
||||||
|
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
||||||
|
</CollapsibleCard>
|
||||||
|
<CollapsibleCard
|
||||||
|
id="application-configuration-images"
|
||||||
|
icon={LucideImage}
|
||||||
|
title={m.images()}
|
||||||
|
description={m.configure_application_images()}
|
||||||
|
>
|
||||||
|
<UpdateApplicationImages callback={updateImages} />
|
||||||
|
</CollapsibleCard>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="user-and-groups" class="flex flex-col gap-5">
|
||||||
|
<CollapsibleCard
|
||||||
|
id="application-configuration-signup-defaults"
|
||||||
|
icon={Users}
|
||||||
|
title={m.user_creation()}
|
||||||
|
description={m.configure_user_creation()}
|
||||||
|
>
|
||||||
|
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
|
||||||
|
</CollapsibleCard>
|
||||||
|
|
||||||
<div>
|
<CollapsibleCard
|
||||||
<CollapsibleCard
|
id="application-configuration-custom-fields"
|
||||||
id="application-configuration-email"
|
icon={ListChecks}
|
||||||
icon={Mail}
|
title={m.custom_fields()}
|
||||||
title={m.email()}
|
description={m.configure_custom_fields()}
|
||||||
description={m.configure_smtp_to_send_emails()}
|
noHeaderMargin
|
||||||
>
|
>
|
||||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
<CustomFieldsTable {appConfig} callback={updateAppConfig} />
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
</div>
|
<CollapsibleCard
|
||||||
|
id="application-configuration-ldap"
|
||||||
<div>
|
icon={UserSearch}
|
||||||
<CollapsibleCard
|
title={m.ldap()}
|
||||||
id="application-configuration-ldap"
|
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
|
||||||
icon={UserSearch}
|
>
|
||||||
title={m.ldap()}
|
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
||||||
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
|
</CollapsibleCard>
|
||||||
>
|
</Tabs.Content>
|
||||||
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
<Tabs.Content value="email">
|
||||||
</CollapsibleCard>
|
<CollapsibleCard
|
||||||
</div>
|
id="application-configuration-email"
|
||||||
|
icon={Mail}
|
||||||
<div>
|
title={m.email()}
|
||||||
<CollapsibleCard
|
description={m.configure_smtp_to_send_emails()}
|
||||||
id="application-configuration-images"
|
>
|
||||||
icon={LucideImage}
|
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||||
title={m.images()}
|
</CollapsibleCard>
|
||||||
description={m.configure_application_images()}
|
</Tabs.Content>
|
||||||
>
|
</Tabs.Root>
|
||||||
<UpdateApplicationImages callback={updateImages} />
|
|
||||||
</CollapsibleCard>
|
|
||||||
</div>
|
|
||||||
|
|||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
|
import type { AllAppConfig } from '$lib/types/application-configuration.type';
|
||||||
|
import type { CustomField } from '$lib/types/custom-field.type';
|
||||||
|
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||||
|
import {
|
||||||
|
LucideCaseSensitive,
|
||||||
|
LucideCheck,
|
||||||
|
LucideHash,
|
||||||
|
LucidePencil,
|
||||||
|
LucidePlus,
|
||||||
|
LucideToggleLeft,
|
||||||
|
LucideTrash,
|
||||||
|
LucideX
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import AppConfigCustomFieldsDialog from './forms/app-config-custom-fields-dialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
appConfig,
|
||||||
|
callback
|
||||||
|
}: {
|
||||||
|
appConfig: AllAppConfig;
|
||||||
|
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let customFields = $state<CustomField[]>(appConfig.customFields);
|
||||||
|
let selectedCustomField = $state<CustomField>();
|
||||||
|
let showCustomFieldsDialog = $state(false);
|
||||||
|
let table = $state<any>();
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<CustomField>[] = [
|
||||||
|
{
|
||||||
|
label: m.display_name(),
|
||||||
|
column: 'displayName',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.key(),
|
||||||
|
column: 'key',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.type(),
|
||||||
|
column: 'type',
|
||||||
|
hidden: true,
|
||||||
|
sortable: true,
|
||||||
|
cell: TypeCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.available_for(),
|
||||||
|
column: 'target',
|
||||||
|
sortable: true,
|
||||||
|
cell: TargetCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.user_editable(),
|
||||||
|
column: 'userEditable',
|
||||||
|
sortable: true,
|
||||||
|
cell: BoolCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.required(),
|
||||||
|
column: 'required',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true,
|
||||||
|
cell: BoolCell
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<CustomField> = (_) => [
|
||||||
|
{
|
||||||
|
label: m.edit(),
|
||||||
|
primary: true,
|
||||||
|
icon: LucidePencil,
|
||||||
|
onClick: (field) => {
|
||||||
|
selectedCustomField = field;
|
||||||
|
showCustomFieldsDialog = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.delete(),
|
||||||
|
icon: LucideTrash,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (field) =>
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.delete_custom_field_name({ name: field.displayName }),
|
||||||
|
message: m.delete_custom_field_description({ name: field.displayName }),
|
||||||
|
confirm: {
|
||||||
|
label: m.delete(),
|
||||||
|
destructive: true,
|
||||||
|
action: () => deleteCustomField(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
selectedCustomField = undefined;
|
||||||
|
showCustomFieldsDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCustomField(customField: CustomField) {
|
||||||
|
const existingIndex = customFields.findIndex((f) => f.id === customField.id);
|
||||||
|
const nextCustomFields = [...customFields];
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
nextCustomFields[existingIndex] = customField;
|
||||||
|
} else {
|
||||||
|
nextCustomFields.push(customField);
|
||||||
|
}
|
||||||
|
await callback({ customFields: nextCustomFields });
|
||||||
|
customFields = nextCustomFields;
|
||||||
|
await table?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomField(customField: CustomField) {
|
||||||
|
const nextCustomFields = customFields.filter((field) => field.id !== customField.id);
|
||||||
|
await callback({ customFields: nextCustomFields });
|
||||||
|
customFields = nextCustomFields;
|
||||||
|
toast.success(m.custom_fields_updated_successfully());
|
||||||
|
await table?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCallback(requestOptions: ListRequestOptions) {
|
||||||
|
const data = [...customFields].sort((a, b) => {
|
||||||
|
const column = requestOptions.sort?.column || 'displayName';
|
||||||
|
const direction = requestOptions.sort?.direction === 'asc' ? 1 : -1;
|
||||||
|
const aValue = String((a as Record<string, unknown>)[column] ?? '');
|
||||||
|
const bValue = String((b as Record<string, unknown>)[column] ?? '');
|
||||||
|
if (aValue < bValue) return -1 * direction;
|
||||||
|
if (aValue > bValue) return 1 * direction;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
itemsPerPage: data.length,
|
||||||
|
totalItems: data.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
customFields = appConfig.customFields;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet TypeCell({ item }: { item: CustomField })}
|
||||||
|
<span class="flex gap-1">
|
||||||
|
{#if item.type === 'boolean'}
|
||||||
|
<LucideToggleLeft class="size-4 my-auto" />
|
||||||
|
{:else if item.type == 'number'}
|
||||||
|
<LucideHash class="size-3 my-auto" />
|
||||||
|
{:else}
|
||||||
|
<LucideCaseSensitive class="size-4 my-auto" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet TargetCell({ item }: { item: CustomField })}
|
||||||
|
{#if item.target === 'both'}
|
||||||
|
<Badge variant="secondary">{m.users_and_groups()}</Badge>
|
||||||
|
{:else if item.target === 'group'}
|
||||||
|
<Badge variant="outline">{m.groups()}</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge variant="outline">{m.users()}</Badge>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet BoolCell({ item }: { item: CustomField })}
|
||||||
|
{#if item.userEditable}
|
||||||
|
<LucideCheck class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<LucideX class="size-4 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<AdvancedTable
|
||||||
|
bind:this={table}
|
||||||
|
id="custom-fields-table"
|
||||||
|
{fetchCallback}
|
||||||
|
{actions}
|
||||||
|
defaultSort={{ column: 'displayName', direction: 'desc' }}
|
||||||
|
paginationDisabled
|
||||||
|
withoutSearch
|
||||||
|
{columns}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end mt-5">
|
||||||
|
<Button onclick={openCreateDialog}>
|
||||||
|
<LucidePlus class="mr-1 size-4" />
|
||||||
|
{m.add_custom_field()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppConfigCustomFieldsDialog
|
||||||
|
bind:show={showCustomFieldsDialog}
|
||||||
|
existingCustomField={selectedCustomField}
|
||||||
|
callback={updateCustomField}
|
||||||
|
/>
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
|
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import * as Field from '$lib/components/ui/field';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type {
|
||||||
|
CustomField,
|
||||||
|
CustomFieldTarget,
|
||||||
|
CustomFieldType
|
||||||
|
} from '$lib/types/custom-field.type';
|
||||||
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
let {
|
||||||
|
show = $bindable(),
|
||||||
|
existingCustomField,
|
||||||
|
callback
|
||||||
|
}: {
|
||||||
|
show?: boolean;
|
||||||
|
existingCustomField?: CustomField;
|
||||||
|
callback: (updatedField: CustomField) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
type CustomFieldForm = CustomField & {
|
||||||
|
defaultValue: string;
|
||||||
|
validationRegex: string;
|
||||||
|
validationErrorMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customField: CustomFieldForm = {
|
||||||
|
id: '',
|
||||||
|
key: '',
|
||||||
|
displayName: '',
|
||||||
|
type: 'string',
|
||||||
|
target: 'user',
|
||||||
|
required: false,
|
||||||
|
userEditable: false,
|
||||||
|
defaultValue: '',
|
||||||
|
validationRegex: '',
|
||||||
|
validationErrorMessage: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldTypes: { value: CustomFieldType; label: string }[] = [
|
||||||
|
{ value: 'string', label: m.text() },
|
||||||
|
{ value: 'number', label: m.number() },
|
||||||
|
{ value: 'boolean', label: m.boolean() }
|
||||||
|
];
|
||||||
|
const fieldTargets: { value: CustomFieldTarget; label: string }[] = [
|
||||||
|
{ value: 'user', label: m.users() },
|
||||||
|
{ value: 'group', label: m.groups() },
|
||||||
|
{ value: 'both', label: m.users_and_groups() }
|
||||||
|
];
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
key: z.string().trim().min(1, { message: m.field_is_required() }),
|
||||||
|
displayName: z.string().trim().min(1, { message: m.field_is_required() }),
|
||||||
|
type: z.enum(['string', 'number', 'boolean']),
|
||||||
|
target: z.enum(['user', 'group', 'both']),
|
||||||
|
required: z.boolean(),
|
||||||
|
userEditable: z.boolean(),
|
||||||
|
defaultValue: z.preprocess(
|
||||||
|
(value) => (value === null || value === undefined ? '' : String(value)),
|
||||||
|
z.string()
|
||||||
|
),
|
||||||
|
validationRegex: z.string().refine(
|
||||||
|
(value) => {
|
||||||
|
if (!value) return true;
|
||||||
|
try {
|
||||||
|
new RegExp(value);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: m.invalid_regex() }
|
||||||
|
),
|
||||||
|
validationErrorMessage: z.string()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.required && !data.defaultValue) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['defaultValue'],
|
||||||
|
message: m.field_is_required()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.defaultValue) return;
|
||||||
|
|
||||||
|
if (data.type === 'number' && Number.isNaN(Number(data.defaultValue))) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['defaultValue'],
|
||||||
|
message: m.must_be_a_number()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.type === 'string' && data.validationRegex) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(data.validationRegex);
|
||||||
|
if (!regex.test(data.defaultValue)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['defaultValue'],
|
||||||
|
message: data.validationErrorMessage || m.value_does_not_match_required_format()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
type FormSchema = typeof formSchema;
|
||||||
|
|
||||||
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, customField);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
|
||||||
|
const field = existingCustomField ?? {
|
||||||
|
...customField,
|
||||||
|
id: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setValue('id', field.id);
|
||||||
|
form.setValue('key', field.key);
|
||||||
|
form.setValue('displayName', field.displayName);
|
||||||
|
form.setValue('type', field.type);
|
||||||
|
form.setValue('target', field.target || 'both');
|
||||||
|
form.setValue('required', field.required);
|
||||||
|
form.setValue('userEditable', !!field.userEditable);
|
||||||
|
form.setValue('defaultValue', field.defaultValue || '');
|
||||||
|
form.setValue('validationRegex', field.validationRegex || '');
|
||||||
|
form.setValue('validationErrorMessage', field.validationErrorMessage || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
await callback(data);
|
||||||
|
toast.success(m.custom_fields_updated_successfully());
|
||||||
|
show = false;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root open={show} onOpenChange={(open) => (show = open)}>
|
||||||
|
<Dialog.Content class="lg:min-w-3xl md:min-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{existingCustomField ? m.edit() : m.add_custom_field()}</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
|
<div class="grid grid-cols-1 items-center gap-5 md:grid-cols-2">
|
||||||
|
<FormInput label={m.key()} bind:input={$inputs.key} />
|
||||||
|
<FormInput label={m.display_name()} bind:input={$inputs.displayName} />
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>{m.type()}</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
disabled={!!existingCustomField}
|
||||||
|
value={$inputs.type.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
$inputs.type.value = value as any;
|
||||||
|
if (value !== 'string') {
|
||||||
|
$inputs.validationRegex.value = '';
|
||||||
|
$inputs.validationErrorMessage.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full" aria-label={m.type()}>
|
||||||
|
{fieldTypes.find((option) => option.value === $inputs.type.value)?.label}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each fieldTypes as option}
|
||||||
|
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>{m.available_for()}</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={$inputs.target.value}
|
||||||
|
onValueChange={(value) => ($inputs.target.value = value as CustomFieldTarget)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full" aria-label={m.available_for()}>
|
||||||
|
{fieldTargets.find((option) => option.value === $inputs.target.value)?.label}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each fieldTargets as option}
|
||||||
|
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</Field.Field>
|
||||||
|
<SwitchWithLabel
|
||||||
|
id="required"
|
||||||
|
class="items-center"
|
||||||
|
label={m.required()}
|
||||||
|
description={m.required_custom_field_description()}
|
||||||
|
bind:checked={$inputs.required.value}
|
||||||
|
/>
|
||||||
|
{#if $inputs.target.value != 'group'}
|
||||||
|
<SwitchWithLabel
|
||||||
|
id="user-editable"
|
||||||
|
class="items-center"
|
||||||
|
label={m.user_editable()}
|
||||||
|
description={m.user_editable_description()}
|
||||||
|
bind:checked={$inputs.userEditable.value}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="hidden md:flex"></div>
|
||||||
|
{/if}
|
||||||
|
{#if $inputs.type.value === 'boolean'}
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label required={$inputs.required.value}>{m.default_value()}</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={$inputs.defaultValue.value || ''}
|
||||||
|
onValueChange={(value) => ($inputs.defaultValue.value = value)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full" aria-label={m.default_value()}>
|
||||||
|
{#if $inputs.defaultValue.value === 'true'}
|
||||||
|
{m.yes()}
|
||||||
|
{:else if $inputs.defaultValue.value === 'false'}
|
||||||
|
{m.no()}
|
||||||
|
{:else}
|
||||||
|
{m.no_default()}
|
||||||
|
{/if}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="">{m.no_default()}</Select.Item>
|
||||||
|
<Select.Item value="true">{m.yes()}</Select.Item>
|
||||||
|
<Select.Item value="false">{m.no()}</Select.Item>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if $inputs.defaultValue.error}
|
||||||
|
<Field.Error class="text-start">{$inputs.defaultValue.error}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
{:else}
|
||||||
|
<FormInput
|
||||||
|
label={m.default_value()}
|
||||||
|
type={$inputs.type.value === 'number' ? 'number' : 'text'}
|
||||||
|
bind:input={$inputs.defaultValue}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if $inputs.type.value === 'string'}
|
||||||
|
<FormInput
|
||||||
|
label={m.validation_regex()}
|
||||||
|
placeholder="^\S+@\S+\.\S+$"
|
||||||
|
bind:input={$inputs.validationRegex}
|
||||||
|
description={m.validation_regex_description()}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label={m.validation_error_message()}
|
||||||
|
placeholder={m.must_be_a_valid_email_address()}
|
||||||
|
bind:input={$inputs.validationErrorMessage}
|
||||||
|
description={m.validation_error_message_description()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer class="mt-10 md:mt-3">
|
||||||
|
<Button onclick={() => (show = false)} variant="secondary">{m.cancel()}</Button>
|
||||||
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
+1
-12
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
|
||||||
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
|
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Field from '$lib/components/ui/field';
|
import * as Field from '$lib/components/ui/field';
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
|
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
|
||||||
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
|
||||||
let allowUserSignups = $state(appConfig.allowUserSignups);
|
let allowUserSignups = $state(appConfig.allowUserSignups);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
@@ -42,15 +40,13 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback({
|
await callback({
|
||||||
allowUserSignups: allowUserSignups,
|
allowUserSignups: allowUserSignups,
|
||||||
signupDefaultUserGroupIDs: selectedGroupIds,
|
signupDefaultUserGroupIDs: selectedGroupIds
|
||||||
signupDefaultCustomClaims: customClaims
|
|
||||||
});
|
});
|
||||||
toast.success(m.user_creation_updated_successfully());
|
toast.success(m.user_creation_updated_successfully());
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
customClaims = appConfig.signupDefaultCustomClaims || [];
|
|
||||||
allowUserSignups = appConfig.allowUserSignups;
|
allowUserSignups = appConfig.allowUserSignups;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -113,13 +109,6 @@
|
|||||||
</Field.Description>
|
</Field.Description>
|
||||||
<UserGroupInput bind:selectedGroupIds />
|
<UserGroupInput bind:selectedGroupIds />
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
<Field.Field>
|
|
||||||
<Field.Label>{m.custom_claims()}</Field.Label>
|
|
||||||
<Field.Description>
|
|
||||||
{m.user_creation_claims_description()}
|
|
||||||
</Field.Description>
|
|
||||||
<CustomClaimsInput bind:customClaims />
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<div class="flex justify-end pt-2">
|
<div class="flex justify-end pt-2">
|
||||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
@@ -27,7 +25,6 @@
|
|||||||
let oidcClientSelectionRef: OidcClientSelection;
|
let oidcClientSelectionRef: OidcClientSelection;
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
const customClaimService = new CustomClaimService();
|
|
||||||
const backNavigation = backNavigate('/settings/admin/user-groups');
|
const backNavigation = backNavigate('/settings/admin/user-groups');
|
||||||
|
|
||||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||||
@@ -52,15 +49,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCustomClaims() {
|
|
||||||
await customClaimService
|
|
||||||
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
|
|
||||||
.then(() => toast.success(m.custom_claims_updated_successfully()))
|
|
||||||
.catch((e) => {
|
|
||||||
axiosErrorToast(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateAllowedOidcClients(allowedClients: string[]) {
|
async function updateAllowedOidcClients(allowedClients: string[]) {
|
||||||
await userGroupService
|
await userGroupService
|
||||||
.updateAllowedOidcClients(userGroup.id, allowedClients)
|
.updateAllowedOidcClients(userGroup.id, allowedClients)
|
||||||
@@ -116,17 +104,6 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<CollapsibleCard
|
|
||||||
id="user-group-custom-claims"
|
|
||||||
title={m.custom_claims()}
|
|
||||||
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
|
|
||||||
>
|
|
||||||
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleCard>
|
|
||||||
|
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
id="user-group-oidc-clients"
|
id="user-group-oidc-clients"
|
||||||
title={m.allowed_oidc_clients()}
|
title={m.allowed_oidc_clients()}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
|
||||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
@@ -19,6 +21,11 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let inputDisabled = $derived(!!existingUserGroup?.ldapId && $appConfigStore.ldapEnabled);
|
let inputDisabled = $derived(!!existingUserGroup?.ldapId && $appConfigStore.ldapEnabled);
|
||||||
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
|
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
|
||||||
|
let customFieldValues = $state(existingUserGroup?.customFieldValues || []);
|
||||||
|
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
|
||||||
|
let customFields = $derived(
|
||||||
|
$appConfigStore.customFields.filter((field) => customFieldAppliesTo(field, 'group'))
|
||||||
|
);
|
||||||
|
|
||||||
const userGroup = {
|
const userGroup = {
|
||||||
name: existingUserGroup?.name || '',
|
name: existingUserGroup?.name || '',
|
||||||
@@ -46,12 +53,18 @@
|
|||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
if (!customFieldValuesInputRef?.validate()) return;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
const success = await callback(data);
|
const success = await callback({
|
||||||
|
...data,
|
||||||
|
customFieldValues
|
||||||
|
});
|
||||||
// Reset form if user group was successfully created
|
// Reset form if user group was successfully created
|
||||||
if (success && !existingUserGroup) {
|
if (success && !existingUserGroup) {
|
||||||
form.reset();
|
form.reset();
|
||||||
hasManualNameEdit = false;
|
hasManualNameEdit = false;
|
||||||
|
customFieldValues = [];
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -59,24 +72,26 @@
|
|||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)}>
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
<fieldset disabled={inputDisabled}>
|
<fieldset disabled={inputDisabled}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<div class="w-full">
|
<FormInput
|
||||||
<FormInput
|
label={m.friendly_name()}
|
||||||
label={m.friendly_name()}
|
description={m.name_that_will_be_displayed_in_the_ui()}
|
||||||
description={m.name_that_will_be_displayed_in_the_ui()}
|
bind:input={$inputs.friendlyName}
|
||||||
bind:input={$inputs.friendlyName}
|
onInput={onFriendlyNameInput}
|
||||||
onInput={onFriendlyNameInput}
|
/>
|
||||||
/>
|
<FormInput
|
||||||
</div>
|
label={m.name()}
|
||||||
<div class="w-full">
|
description={m.name_that_will_be_in_the_groups_claim()}
|
||||||
<FormInput
|
bind:input={$inputs.name}
|
||||||
label={m.name()}
|
onInput={onNameInput}
|
||||||
description={m.name_that_will_be_in_the_groups_claim()}
|
/>
|
||||||
bind:input={$inputs.name}
|
<CustomFieldValuesInput
|
||||||
onInput={onNameInput}
|
bind:this={customFieldValuesInputRef}
|
||||||
/>
|
bind:customFieldValues
|
||||||
</div>
|
{customFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
|
||||||
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
|
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
|
||||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -8,7 +7,6 @@
|
|||||||
import * as Item from '$lib/components/ui/item/index.js';
|
import * as Item from '$lib/components/ui/item/index.js';
|
||||||
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { Passkey } from '$lib/types/passkey.type';
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
@@ -28,7 +26,6 @@
|
|||||||
let passkeys: Passkey[] = $state(data.passkeys);
|
let passkeys: Passkey[] = $state(data.passkeys);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const customClaimService = new CustomClaimService();
|
|
||||||
const backNavigation = backNavigate('/settings/admin/users');
|
const backNavigation = backNavigate('/settings/admin/users');
|
||||||
|
|
||||||
async function updateUserGroups(userIds: string[]) {
|
async function updateUserGroups(userIds: string[]) {
|
||||||
@@ -53,15 +50,6 @@
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCustomClaims() {
|
|
||||||
await customClaimService
|
|
||||||
.updateUserCustomClaims(user.id, user.customClaims)
|
|
||||||
.then(() => toast.success(m.custom_claims_updated_successfully()))
|
|
||||||
.catch((e) => {
|
|
||||||
axiosErrorToast(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateProfilePicture(image: File) {
|
async function updateProfilePicture(image: File) {
|
||||||
await userService
|
await userService
|
||||||
.updateProfilePicture(user.id, image)
|
.updateProfilePicture(user.id, image)
|
||||||
@@ -150,14 +138,3 @@
|
|||||||
<AdminPasskeyList userId={user.id} bind:passkeys />
|
<AdminPasskeyList userId={user.id} bind:passkeys />
|
||||||
{/if}
|
{/if}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
|
|
||||||
<CollapsibleCard
|
|
||||||
id="user-custom-claims"
|
|
||||||
title={m.custom_claims()}
|
|
||||||
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
|
|
||||||
>
|
|
||||||
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleCard>
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
|
||||||
import type { User, UserCreate } from '$lib/types/user.type';
|
import type { User, UserCreate } from '$lib/types/user.type';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
@@ -28,6 +30,11 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
|
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
|
||||||
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
|
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
|
||||||
|
let customFieldValues = $state(existingUser?.customFieldValues || []);
|
||||||
|
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
|
||||||
|
let customFields = $derived(
|
||||||
|
$appConfigStore.customFields.filter((field) => customFieldAppliesTo(field, 'user'))
|
||||||
|
);
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
firstName: existingUser?.firstName || '',
|
firstName: existingUser?.firstName || '',
|
||||||
@@ -58,10 +65,17 @@
|
|||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
if (!customFieldValuesInputRef?.validate()) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
const success = await callback(data);
|
const success = await callback({
|
||||||
|
...data,
|
||||||
|
customFieldValues
|
||||||
|
});
|
||||||
// Reset form if user was successfully created
|
// Reset form if user was successfully created
|
||||||
if (success && !existingUser) form.reset();
|
if (success && !existingUser) {
|
||||||
|
form.reset();
|
||||||
|
customFieldValues = [];
|
||||||
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
function onNameInput() {
|
function onNameInput() {
|
||||||
@@ -129,6 +143,11 @@
|
|||||||
description={m.disabled_users_cannot_log_in_or_use_services()}
|
description={m.disabled_users_cannot_log_in_or_use_services()}
|
||||||
bind:checked={$inputs.disabled.value}
|
bind:checked={$inputs.disabled.value}
|
||||||
/>
|
/>
|
||||||
|
<CustomFieldValuesInput
|
||||||
|
bind:this={customFieldValuesInputRef}
|
||||||
|
bind:customFieldValues
|
||||||
|
{customFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
|
|||||||
@@ -161,3 +161,41 @@ export const signupTokens = {
|
|||||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const customFields = {
|
||||||
|
department: {
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
elevatedRights: {
|
||||||
|
id: '8d081fd8-6a51-45a1-8051-04c3b043f5bd',
|
||||||
|
key: 'elevatedRights',
|
||||||
|
type: 'boolean',
|
||||||
|
displayName: 'Elevated Rights',
|
||||||
|
target: 'group'
|
||||||
|
},
|
||||||
|
internalId: {
|
||||||
|
id: '3d7c6054-e146-48cb-b2d3-7d7897dcbc51',
|
||||||
|
key: 'internalId',
|
||||||
|
type: 'number',
|
||||||
|
userEditable: false,
|
||||||
|
displayName: 'Internal ID',
|
||||||
|
target: 'both',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
nickname: {
|
||||||
|
id: '0b68d19a-bb72-4750-84b4-2f0992f5200c',
|
||||||
|
key: 'department',
|
||||||
|
type: 'string',
|
||||||
|
userEditable: true,
|
||||||
|
required: true,
|
||||||
|
displayName: 'Nickname',
|
||||||
|
target: 'user'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
{
|
{
|
||||||
"key": "instanceId",
|
"key": "instanceId",
|
||||||
"value": "test-instance-id"
|
"value": "test-instance-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "customFields",
|
||||||
|
"value": "[{\"id\":\"189356b1-57f3-4c14-bd59-3ae1132a36d1\",\"key\":\"department\",\"displayName\":\"Department\",\"type\":\"string\",\"target\":\"user\",\"required\":false,\"userEditable\":true,\"defaultValue\":\"\",\"validationRegex\":\"^[A-Za-z]+$\",\"validationErrorMessage\":\"Department must only contain letters\"},{\"id\":\"0b68d19a-bb72-4750-84b4-2f0992f5200c\",\"key\":\"nickname\",\"displayName\":\"Nickname\",\"type\":\"string\",\"target\":\"user\",\"required\":true,\"userEditable\":true,\"defaultValue\":\"to-remove\",\"validationRegex\":\"\",\"validationErrorMessage\":\"\"},{\"id\":\"8d081fd8-6a51-45a1-8051-04c3b043f5bd\",\"key\":\"elevatedRights\",\"displayName\":\"Elevated Rights\",\"type\":\"boolean\",\"target\":\"group\",\"required\":false,\"userEditable\":false,\"defaultValue\":\"\",\"validationRegex\":\"\",\"validationErrorMessage\":\"\"},{\"id\":\"3d7c6054-e146-48cb-b2d3-7d7897dcbc51\",\"key\":\"internalId\",\"displayName\":\"Internal ID\",\"type\":\"number\",\"target\":\"both\",\"required\":true,\"userEditable\":false,\"defaultValue\":\"0\",\"validationRegex\":\"\",\"validationErrorMessage\":\"\"}]"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"kv": [
|
"kv": [
|
||||||
|
|||||||
@@ -60,6 +60,53 @@ user_exists() {
|
|||||||
printf '%s' "$response" | jq -e --arg id "$1" '.data.users[]? | select(.id == $id)' >/dev/null
|
printf '%s' "$response" | jq -e --arg id "$1" '.data.users[]? | select(.id == $id)' >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schema_attribute_exists() {
|
||||||
|
target="$1"
|
||||||
|
name="$2"
|
||||||
|
response="$(graphql '{schema{userSchema{attributes{name}} groupSchema{attributes{name}}}}' '{}')"
|
||||||
|
|
||||||
|
if [ "$target" = "user" ]; then
|
||||||
|
printf '%s' "$response" | jq -e --arg name "$name" '.data.schema.userSchema.attributes[]? | select(.name == $name)' >/dev/null
|
||||||
|
else
|
||||||
|
printf '%s' "$response" | jq -e --arg name "$name" '.data.schema.groupSchema.attributes[]? | select(.name == $name)' >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_schema_attribute() {
|
||||||
|
target="$1"
|
||||||
|
name="$2"
|
||||||
|
attribute_type="$3"
|
||||||
|
|
||||||
|
if schema_attribute_exists "$target" "$name"; then
|
||||||
|
echo "Schema attribute already exists: $target.$name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$target" = "user" ]; then
|
||||||
|
graphql "mutation {
|
||||||
|
addUserAttribute(
|
||||||
|
name: \"$name\"
|
||||||
|
attributeType: $attribute_type
|
||||||
|
isList: false
|
||||||
|
isVisible: true
|
||||||
|
isEditable: true
|
||||||
|
) { ok }
|
||||||
|
}" >/dev/null
|
||||||
|
else
|
||||||
|
graphql "mutation {
|
||||||
|
addGroupAttribute(
|
||||||
|
name: \"$name\"
|
||||||
|
attributeType: $attribute_type
|
||||||
|
isList: false
|
||||||
|
isVisible: true
|
||||||
|
isEditable: true
|
||||||
|
) { ok }
|
||||||
|
}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created schema attribute: $target.$name"
|
||||||
|
}
|
||||||
|
|
||||||
create_user() {
|
create_user() {
|
||||||
id="$1"
|
id="$1"
|
||||||
email="$2"
|
email="$2"
|
||||||
@@ -67,6 +114,7 @@ create_user() {
|
|||||||
first_name="$4"
|
first_name="$4"
|
||||||
last_name="$5"
|
last_name="$5"
|
||||||
password="$6"
|
password="$6"
|
||||||
|
department="$7"
|
||||||
|
|
||||||
variables="$(
|
variables="$(
|
||||||
jq -cn \
|
jq -cn \
|
||||||
@@ -75,7 +123,18 @@ create_user() {
|
|||||||
--arg displayName "$display_name" \
|
--arg displayName "$display_name" \
|
||||||
--arg firstName "$first_name" \
|
--arg firstName "$first_name" \
|
||||||
--arg lastName "$last_name" \
|
--arg lastName "$last_name" \
|
||||||
'{user: {id: $id, email: $email, displayName: $displayName, firstName: $firstName, lastName: $lastName, avatar: ""}}'
|
--arg department "$department" \
|
||||||
|
'{user: {
|
||||||
|
id: $id,
|
||||||
|
email: $email,
|
||||||
|
displayName: $displayName,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
avatar: "",
|
||||||
|
attributes: [
|
||||||
|
{name: "department", value: [$department]}
|
||||||
|
]
|
||||||
|
}}'
|
||||||
)"
|
)"
|
||||||
|
|
||||||
graphql 'mutation createUser($user:CreateUserInput!){createUser(user:$user){id}}' "$variables" >/dev/null
|
graphql 'mutation createUser($user:CreateUserInput!){createUser(user:$user){id}}' "$variables" >/dev/null
|
||||||
@@ -112,13 +171,35 @@ update_group_display_name() {
|
|||||||
jq -cn \
|
jq -cn \
|
||||||
--argjson id "$group_id" \
|
--argjson id "$group_id" \
|
||||||
--arg displayName "$display_name" \
|
--arg displayName "$display_name" \
|
||||||
'{group: {id: $id, insertAttributes: {name: "display_name", value: $displayName}}}'
|
'{group: {id: $id, insertAttributes: {name: "display_name", value: [$displayName]}}}'
|
||||||
)"
|
)"
|
||||||
|
|
||||||
graphql 'mutation updateGroup($group:UpdateGroupInput!){updateGroup(group:$group){ok}}' "$variables" >/dev/null
|
graphql 'mutation updateGroup($group:UpdateGroupInput!){updateGroup(group:$group){ok}}' "$variables" >/dev/null
|
||||||
echo "Attribute set for group: $name, attribute: display_name, value: $display_name"
|
echo "Attribute set for group: $name, attribute: display_name, value: $display_name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update_group_elevated_rights() {
|
||||||
|
name="$1"
|
||||||
|
elevated_rights="$2"
|
||||||
|
group_id="$(get_group_id "$name")"
|
||||||
|
|
||||||
|
variables="$(
|
||||||
|
jq -cn \
|
||||||
|
--argjson id "$group_id" \
|
||||||
|
--arg elevatedRights "$elevated_rights" \
|
||||||
|
'{group: {
|
||||||
|
id: $id,
|
||||||
|
insertAttributes: {
|
||||||
|
name: "elevatedRights",
|
||||||
|
value: [$elevatedRights]
|
||||||
|
}
|
||||||
|
}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
graphql 'mutation updateGroup($group:UpdateGroupInput!){updateGroup(group:$group){ok}}' "$variables" >/dev/null
|
||||||
|
echo "Attribute set for group: $name, attribute: elevatedRights, value: $elevated_rights"
|
||||||
|
}
|
||||||
|
|
||||||
add_user_to_group() {
|
add_user_to_group() {
|
||||||
user_id="$1"
|
user_id="$1"
|
||||||
group_name="$2"
|
group_name="$2"
|
||||||
@@ -174,6 +255,10 @@ done
|
|||||||
|
|
||||||
login
|
login
|
||||||
|
|
||||||
|
echo "Setting up LLDAP schema..."
|
||||||
|
create_schema_attribute "user" "department" "STRING"
|
||||||
|
create_schema_attribute "group" "elevatedRights" "STRING"
|
||||||
|
|
||||||
echo "Checking if data is already seeded..."
|
echo "Checking if data is already seeded..."
|
||||||
if user_exists "testuser1"; then
|
if user_exists "testuser1"; then
|
||||||
echo "Data already seeded, skipping setup."
|
echo "Data already seeded, skipping setup."
|
||||||
@@ -183,17 +268,19 @@ fi
|
|||||||
echo "Setting up LLDAP test data..."
|
echo "Setting up LLDAP test data..."
|
||||||
|
|
||||||
echo "Creating test users..."
|
echo "Creating test users..."
|
||||||
create_user "testuser1" "testuser1@pocket-id.org" "Test User 1" "Test" "User" "password123"
|
create_user "testuser1" "testuser1@pocket-id.org" "Test User 1" "Test" "User" "password123" "Engineering"
|
||||||
create_user "testuser2" "testuser2@pocket-id.org" "Test User 2" "Test2" "User2" "password123"
|
create_user "testuser2" "testuser2@pocket-id.org" "Test User 2" "Test2" "User2" "password123" "Operations"
|
||||||
|
|
||||||
echo "Creating test groups..."
|
echo "Creating test groups..."
|
||||||
create_group "test_group"
|
create_group "test_group"
|
||||||
sleep 1
|
sleep 1
|
||||||
update_group_display_name "test_group" "test_group"
|
update_group_display_name "test_group" "test_group"
|
||||||
|
update_group_elevated_rights "test_group" "false"
|
||||||
|
|
||||||
create_group "admin_group"
|
create_group "admin_group"
|
||||||
sleep 1
|
sleep 1
|
||||||
update_group_display_name "admin_group" "admin_group"
|
update_group_display_name "admin_group" "admin_group"
|
||||||
|
update_group_elevated_rights "admin_group" "true"
|
||||||
|
|
||||||
echo "Adding users to groups..."
|
echo "Adding users to groups..."
|
||||||
add_user_to_group_with_retry "testuser1" "test_group"
|
add_user_to_group_with_retry "testuser1" "test_group"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { emailVerificationTokens, users } from '../data';
|
import { customFields, emailVerificationTokens, users } from '../data';
|
||||||
import authUtil from '../utils/auth.util';
|
import authUtil from '../utils/auth.util';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
@@ -22,6 +22,31 @@ test('Update account details', async ({ page }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update self editable custom fields from account settings', async ({ page }) => {
|
||||||
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
|
await expect(page.getByLabel(customFields.internalId.displayName)).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(customFields.department.displayName).fill('Engineering3');
|
||||||
|
await page.getByLabel(customFields.nickname.displayName).fill('');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
await expect(page.getByText('Department must only contain letters')).toBeVisible();
|
||||||
|
await expect(page.getByText('Field is required')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(customFields.department.displayName).fill('Design');
|
||||||
|
await page.getByLabel(customFields.nickname.displayName).fill('Timmy');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
|
'Account details updated successfully'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Design');
|
||||||
|
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('Timmy');
|
||||||
|
});
|
||||||
|
|
||||||
test('Update account details fails with already taken email', async ({ page }) => {
|
test('Update account details fails with already taken email', async ({ page }) => {
|
||||||
await page.goto('/settings/account');
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -31,14 +31,15 @@ test('Update general configuration', async ({ page }) => {
|
|||||||
|
|
||||||
test.describe('Update user creation configuration', () => {
|
test.describe('Update user creation configuration', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
await page.getByRole('tab', { name: 'Users and groups' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should save sign up mode', async ({ page }) => {
|
test('should save sign up mode', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Enable User Signups' }).click();
|
await page.getByRole('button', { name: 'Enable User Signups' }).click();
|
||||||
await page.getByRole('option', { name: 'Open Signup' }).click();
|
await page.getByRole('option', { name: 'Open Signup' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
'User creation settings updated successfully.'
|
'User creation settings updated successfully.'
|
||||||
@@ -55,7 +56,7 @@ test.describe('Update user creation configuration', () => {
|
|||||||
await page.getByRole('option', { name: 'Designers' }).click();
|
await page.getByRole('option', { name: 'Designers' }).click();
|
||||||
await page.getByRole('combobox', { name: 'User Groups' }).click();
|
await page.getByRole('combobox', { name: 'User Groups' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
'User creation settings updated successfully.'
|
'User creation settings updated successfully.'
|
||||||
@@ -69,31 +70,131 @@ test.describe('Update user creation configuration', () => {
|
|||||||
await expect(page.getByRole('option', { name: 'Designers' })).toBeChecked();
|
await expect(page.getByRole('option', { name: 'Designers' })).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should save default custom claims for new signups', async ({ page }) => {
|
test('should create, edit, validate, and delete custom fields', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
await openCustomFieldsCard(page);
|
||||||
await page.getByPlaceholder('Key').fill('test-claim');
|
|
||||||
await page.getByPlaceholder('Value').fill('test-value');
|
|
||||||
await page.getByRole('button', { name: 'Add another' }).click();
|
|
||||||
await page.getByPlaceholder('Key').nth(1).fill('another-claim');
|
|
||||||
await page.getByPlaceholder('Value').nth(1).fill('another-value');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await addCustomField(page, {
|
||||||
|
displayName: 'Employee code',
|
||||||
|
key: 'employeeCode',
|
||||||
|
defaultValue: 'WRONG',
|
||||||
|
validationRegex: '^EMP-[0-9]+$',
|
||||||
|
validationErrorMessage: 'Use EMP-###'
|
||||||
|
});
|
||||||
|
await expect(page.getByText('Use EMP-###')).toBeVisible();
|
||||||
|
await page.getByLabel('Default value').fill('EMP-001');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
'User creation settings updated successfully.'
|
'Custom fields updated successfully'
|
||||||
|
);
|
||||||
|
|
||||||
|
await addCustomField(page, {
|
||||||
|
displayName: 'Weekly hours',
|
||||||
|
key: 'weeklyHours',
|
||||||
|
type: 'Number',
|
||||||
|
target: 'Users and groups',
|
||||||
|
required: true,
|
||||||
|
defaultValue: '40'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
|
'Custom fields updated successfully'
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('row', { name: /Employee code/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('row', { name: /Weekly hours/ })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('row', { name: /Employee code/ }).click();
|
||||||
|
await expect(page.getByLabel('Type')).toBeDisabled();
|
||||||
|
await page.getByLabel('Display Name').fill('Employee code updated');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
|
'Custom fields updated successfully'
|
||||||
|
);
|
||||||
|
|
||||||
|
const weeklyHoursRow = page.getByRole('row', { name: /Weekly hours/ });
|
||||||
|
await weeklyHoursRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
|
'Custom fields updated successfully'
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('test-claim');
|
await page.getByRole('tab', { name: 'Users and groups' }).click();
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('test-value');
|
await openCustomFieldsCard(page, false);
|
||||||
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('another-claim');
|
await expect(page.getByText('Employee code updated')).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('another-value');
|
await expect(page.getByText('Weekly hours')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function openCustomFieldsCard(page: Page, navigate = true) {
|
||||||
|
if (navigate) {
|
||||||
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
await page.getByRole('tab', { name: 'Users and groups' }).click();
|
||||||
|
}
|
||||||
|
const addButton = page.getByRole('button', { name: 'Add custom field' });
|
||||||
|
if (!(await addButton.isVisible().catch(() => false))) {
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||||
|
}
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCustomField(
|
||||||
|
page: Page,
|
||||||
|
field: {
|
||||||
|
displayName: string;
|
||||||
|
key: string;
|
||||||
|
type?: 'Text' | 'Number' | 'Boolean';
|
||||||
|
target?: 'Users' | 'Groups' | 'Users and groups';
|
||||||
|
required?: boolean;
|
||||||
|
userEditable?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
validationRegex?: string;
|
||||||
|
validationErrorMessage?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
await page.getByRole('button', { name: 'Add custom field' }).click();
|
||||||
|
await page.getByLabel('Display Name').fill(field.displayName);
|
||||||
|
await page.getByLabel('Key').fill(field.key);
|
||||||
|
|
||||||
|
if (field.type && field.type !== 'Text') {
|
||||||
|
await page.getByLabel('Type').click();
|
||||||
|
await page.getByRole('option', { name: field.type, exact: true }).click();
|
||||||
|
}
|
||||||
|
if (field.target && field.target !== 'Users') {
|
||||||
|
await page.getByLabel('Available for').click();
|
||||||
|
await page.getByRole('option', { name: field.target, exact: true }).click();
|
||||||
|
}
|
||||||
|
if (field.required) {
|
||||||
|
await page.getByLabel('Required').click();
|
||||||
|
}
|
||||||
|
if (field.userEditable) {
|
||||||
|
await page.getByLabel('User editable').click();
|
||||||
|
}
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
if (field.type === 'Boolean') {
|
||||||
|
await page.getByLabel('Default value').click();
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: field.defaultValue === 'true' ? 'Yes' : 'No', exact: true })
|
||||||
|
.click();
|
||||||
|
} else {
|
||||||
|
await page.getByLabel('Default value').fill(field.defaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.validationRegex) {
|
||||||
|
await page.getByLabel('Validation regex').fill(field.validationRegex);
|
||||||
|
}
|
||||||
|
if (field.validationErrorMessage) {
|
||||||
|
await page.getByLabel('Validation error message').fill(field.validationErrorMessage);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
}
|
||||||
|
|
||||||
test('Update email configuration', async ({ page }) => {
|
test('Update email configuration', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
|
await page.getByRole('tab', { name: 'Email' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||||
|
|
||||||
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
|
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
|
||||||
await page.getByLabel('SMTP Port').fill('587');
|
await page.getByLabel('SMTP Port').fill('587');
|
||||||
@@ -105,7 +206,7 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
await page.getByLabel('Email Login Code from Admin').click();
|
await page.getByLabel('Email Login Code from Admin').click();
|
||||||
await page.getByLabel('API Key Expiration').click();
|
await page.getByLabel('API Key Expiration').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
'Email configuration updated successfully'
|
'Email configuration updated successfully'
|
||||||
@@ -126,7 +227,7 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
|
|
||||||
test.describe('Update application images', () => {
|
test.describe('Update application images', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
|
await page.getByRole('button', { name: 'Expand card' }).last().click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should upload images', async ({ page }) => {
|
test('should upload images', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { customFields } from 'data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
test.beforeEach(async () => await cleanupBackend());
|
test.beforeEach(async () => await cleanupBackend());
|
||||||
@@ -12,7 +13,8 @@ test.describe('LDAP Integration', () => {
|
|||||||
test('LDAP configuration is working properly', async ({ page }) => {
|
test('LDAP configuration is working properly', async ({ page }) => {
|
||||||
await page.goto('/settings/admin/application-configuration');
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(3).click();
|
await page.getByRole('tab', { name: 'Users and groups' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
|
||||||
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
|
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
|
||||||
@@ -66,6 +68,25 @@ test.describe('LDAP Integration', () => {
|
|||||||
await expect(page.getByText('LDAP').first()).toBeVisible();
|
await expect(page.getByText('LDAP').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('LDAP custom fields are synced into users and groups', async ({ page }) => {
|
||||||
|
await page.goto('/settings/admin/users');
|
||||||
|
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Engineering');
|
||||||
|
|
||||||
|
await page.goto('/settings/admin/user-groups');
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: 'admin_group' })
|
||||||
|
.getByRole('button', { name: 'Toggle menu' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
|
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
|
||||||
// Navigate to LDAP user details
|
// Navigate to LDAP user details
|
||||||
await page.goto('/settings/admin/users');
|
await page.goto('/settings/admin/users');
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import test, { expect, type Page, type Request } from '@playwright/test';
|
import test, { expect, type Page, type Request } from '@playwright/test';
|
||||||
import { oidcClients, refreshTokens, users } from '../data';
|
import * as jose from 'jose';
|
||||||
|
import { customFields, oidcClients, refreshTokens, userGroups, users } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
import {
|
||||||
|
updateUserCustomFieldsViaApi,
|
||||||
|
updateUserGroupCustomFieldsViaApi
|
||||||
|
} from '../utils/custom-fields.util';
|
||||||
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
|
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
|
||||||
import * as oidcUtil from '../utils/oidc.util';
|
import * as oidcUtil from '../utils/oidc.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
@@ -130,7 +135,11 @@ test('End session without id token hint shows confirmation page', async ({ page
|
|||||||
|
|
||||||
test('End session with id token hint redirects to callback URL', async ({ page }) => {
|
test('End session with id token hint redirects to callback URL', async ({ page }) => {
|
||||||
const client = oidcClients.nextcloud;
|
const client = oidcClients.nextcloud;
|
||||||
const idToken = await generateIdToken("fe81c12a-7336-4aee-bebc-d901a873bf48", users.tim, client.id);
|
const idToken = await generateIdToken(
|
||||||
|
'fe81c12a-7336-4aee-bebc-d901a873bf48',
|
||||||
|
users.tim,
|
||||||
|
client.id
|
||||||
|
);
|
||||||
let redirectedCorrectly = false;
|
let redirectedCorrectly = false;
|
||||||
await page
|
await page
|
||||||
.goto(
|
.goto(
|
||||||
@@ -190,6 +199,51 @@ test('Successfully refresh tokens with valid refresh token', async ({ request })
|
|||||||
expect(tokenData.refresh_token).not.toBe(token);
|
expect(tokenData.refresh_token).not.toBe(token);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ID token includes configured custom fields when profile scope is requested', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await updateUserCustomFieldsViaApi(page, users.tim.id, [
|
||||||
|
{ customFieldId: customFields.department.id, value: 'Engineering' },
|
||||||
|
{ customFieldId: customFields.nickname.id, value: 'Timi' }
|
||||||
|
]);
|
||||||
|
await updateUserGroupCustomFieldsViaApi(page, userGroups.designers.id, [
|
||||||
|
{ customFieldId: customFields.elevatedRights.id, value: 'true' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { token, clientId, userId } = refreshTokens.filter(
|
||||||
|
(refreshToken) => !refreshToken.expired
|
||||||
|
)[0];
|
||||||
|
const refreshToken = await page.request
|
||||||
|
.post('/api/test/refreshtoken', {
|
||||||
|
data: {
|
||||||
|
rt: token,
|
||||||
|
client: clientId,
|
||||||
|
user: userId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.text());
|
||||||
|
|
||||||
|
const refreshResponse = await page.request.post('/api/oidc/token', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: clientId,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_secret: oidcClients.nextcloud.secret
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshResponse.ok()).toBeTruthy();
|
||||||
|
const tokenData = await refreshResponse.json();
|
||||||
|
const idToken = jose.decodeJwt(tokenData.id_token);
|
||||||
|
|
||||||
|
expect(idToken.department).toBe('Engineering');
|
||||||
|
expect(idToken.nickname).toBe('Timi');
|
||||||
|
expect(idToken.elevatedRights).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('Refresh token fails when used for the wrong client', async ({ request }) => {
|
test('Refresh token fails when used for the wrong client', async ({ request }) => {
|
||||||
const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0];
|
const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0];
|
||||||
const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY';
|
const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { oidcClients, userGroups, users } from '../data';
|
import { customFields, oidcClients, userGroups, users } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
test.beforeEach(async () => await cleanupBackend());
|
test.beforeEach(async () => await cleanupBackend());
|
||||||
@@ -74,56 +74,29 @@ test('Delete user group', async ({ page }) => {
|
|||||||
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update user group custom claims', async ({ page }) => {
|
test('Update user group custom fields', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
await expect(
|
||||||
|
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
|
||||||
|
).not.toBeChecked();
|
||||||
|
|
||||||
// Add two custom claims
|
await page.getByRole('switch', { name: customFields.elevatedRights.displayName }).click();
|
||||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Key').fill('customClaim1');
|
await expect(page.locator('[data-type="success"]')).toHaveText('User group updated successfully');
|
||||||
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add another' }).click();
|
|
||||||
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
|
||||||
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
||||||
'Custom claims updated successfully'
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Check if custom claims are saved
|
await expect(
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
).toBeChecked();
|
||||||
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
|
||||||
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
|
||||||
|
|
||||||
// Remove one custom claim
|
|
||||||
await page.getByLabel('Remove custom claim').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
||||||
'Custom claims updated successfully'
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Check if custom claim is removed
|
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update user group allowed user groups', async ({ page }) => {
|
test('Update user group allowed user groups', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
await page.getByRole('button', { name: 'Expand card' }).click();
|
||||||
|
|
||||||
// Unrestricted OIDC clients should be checked and disabled
|
// Unrestricted OIDC clients should be checked and disabled
|
||||||
const nextcloudRow = page
|
const nextcloudRow = page
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { userGroups, users } from '../data';
|
import { customFields, userGroups, users } from '../data';
|
||||||
import authUtil from '../utils/auth.util';
|
import authUtil from '../utils/auth.util';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
@@ -184,48 +184,27 @@ test('Update user fails with already taken username in different casing', async
|
|||||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update user custom claims', async ({ page }) => {
|
test('Update user custom fields', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
await page.getByLabel(customFields.department.displayName).fill('Engineering3');
|
||||||
|
await page.getByLabel(customFields.nickname.displayName).fill('');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
await expect(page.getByText('Department must only contain letters')).toBeVisible();
|
||||||
|
await expect(page.getByText('Field is required')).toBeVisible();
|
||||||
|
|
||||||
// Add two custom claims
|
await page.getByLabel(customFields.department.displayName).fill('Design');
|
||||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
await page.getByLabel(customFields.internalId.displayName).fill('123');
|
||||||
|
await page.getByLabel(customFields.nickname.displayName).fill('Timmy');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Key').fill('customClaim1');
|
await expect(page.locator('[data-type="success"]')).toHaveText('User updated successfully');
|
||||||
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add another' }).click();
|
|
||||||
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
|
||||||
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
||||||
'Custom claims updated successfully'
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Check if custom claims are saved
|
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Design');
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('Timmy');
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
await expect(page.getByLabel(customFields.internalId.displayName)).toHaveValue('123');
|
||||||
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
|
||||||
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
|
||||||
|
|
||||||
// Remove one custom claim
|
|
||||||
await page.getByLabel('Remove custom claim').first().click();
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
||||||
'Custom claims updated successfully'
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Check if custom claim is removed
|
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update user group assignments', async ({ page }) => {
|
test('Update user group assignments', async ({ page }) => {
|
||||||
@@ -263,7 +242,11 @@ test('Admin can view another user passkeys', async ({ page }) => {
|
|||||||
test('Admin can delete another user passkey and audit log is created', async ({ page }) => {
|
test('Admin can delete another user passkey and audit log is created', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
await page.locator('[data-slot="item"]').filter({ hasText: 'Passkey 2' }).getByLabel('Delete').click();
|
await page
|
||||||
|
.locator('[data-slot="item"]')
|
||||||
|
.filter({ hasText: 'Passkey 2' })
|
||||||
|
.getByLabel('Delete')
|
||||||
|
.click();
|
||||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test, { expect, type Page } from '@playwright/test';
|
import test, { expect, type Page } from '@playwright/test';
|
||||||
import { signupTokens, userGroups, users } from '../data';
|
import { customFields, signupTokens, userGroups, users } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
|
|
||||||
@@ -10,10 +10,11 @@ async function setSignupMode(
|
|||||||
) {
|
) {
|
||||||
await page.goto('/settings/admin/application-configuration');
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
await page.getByRole('tab', { name: 'Users and groups' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||||
await page.getByRole('button', { name: 'Enable User Signups' }).click();
|
await page.getByRole('button', { name: 'Enable User Signups' }).click();
|
||||||
await page.getByRole('option', { name: mode }).click();
|
await page.getByRole('option', { name: mode }).click();
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
await expect(page.locator('[data-type="success"]').last()).toHaveText(
|
||||||
'User creation settings updated successfully.'
|
'User creation settings updated successfully.'
|
||||||
@@ -117,7 +118,7 @@ test.describe('User Signup', () => {
|
|||||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||||
|
|
||||||
const response = await page.request.get('/api/users/me').then((res) => res.json());
|
const response = await page.request.get('/api/users/me').then((res) => res.json());
|
||||||
expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id);
|
expect(response.userGroups.map((g: any) => g.id)).toContain(userGroups.developers.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Signup with token - invalid token shows error', async ({ page }) => {
|
test('Signup with token - invalid token shows error', async ({ page }) => {
|
||||||
@@ -161,6 +162,32 @@ test.describe('User Signup', () => {
|
|||||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Open signup - required custom fields are shown and saved', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup', false);
|
||||||
|
await page.context().clearCookies();
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await expect(page.getByLabel(customFields.nickname.displayName)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(customFields.elevatedRights.displayName)).not.toBeVisible();
|
||||||
|
await expect(page.getByLabel(customFields.department.displayName)).not.toBeVisible();
|
||||||
|
await expect(page.getByLabel(customFields.internalId.displayName)).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('First name').fill('Jane');
|
||||||
|
await page.getByLabel('Last name').fill('Smith');
|
||||||
|
await page.getByLabel('Username').fill('janecustomfield');
|
||||||
|
await page.getByLabel('Email').fill('jane.customfield@test.com');
|
||||||
|
await page.getByLabel(customFields.nickname.displayName).fill('123');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('123');
|
||||||
|
});
|
||||||
|
|
||||||
test('Open signup - validation errors', async ({ page }) => {
|
test('Open signup - validation errors', async ({ page }) => {
|
||||||
await setSignupMode(page, 'Open Signup');
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export type CustomField = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
type: 'string' | 'number' | 'boolean';
|
||||||
|
target: 'user' | 'group' | 'both';
|
||||||
|
required: boolean;
|
||||||
|
userEditable: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
validationRegex?: string;
|
||||||
|
validationErrorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomFieldValue = {
|
||||||
|
customFieldId: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateUserCustomFieldsViaApi(
|
||||||
|
page: Page,
|
||||||
|
userId: string,
|
||||||
|
customFieldValues: CustomFieldValue[]
|
||||||
|
) {
|
||||||
|
const userResponse = await page.request.get(`/api/users/${userId}`);
|
||||||
|
expect(userResponse.ok()).toBeTruthy();
|
||||||
|
const user = await userResponse.json();
|
||||||
|
|
||||||
|
const updateResponse = await page.request.put(`/api/users/${userId}`, {
|
||||||
|
data: {
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
disabled: user.disabled,
|
||||||
|
customFieldValues
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserGroupCustomFieldsViaApi(
|
||||||
|
page: Page,
|
||||||
|
userGroupId: string,
|
||||||
|
customFieldValues: CustomFieldValue[]
|
||||||
|
) {
|
||||||
|
const userGroupResponse = await page.request.get(`/api/user-groups/${userGroupId}`);
|
||||||
|
expect(userGroupResponse.ok()).toBeTruthy();
|
||||||
|
const userGroup = await userGroupResponse.json();
|
||||||
|
|
||||||
|
const updateResponse = await page.request.put(`/api/user-groups/${userGroupId}`, {
|
||||||
|
data: {
|
||||||
|
friendlyName: userGroup.friendlyName,
|
||||||
|
name: userGroup.name,
|
||||||
|
ldapId: userGroup.ldapId,
|
||||||
|
customFieldValues
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user