diff --git a/README.md b/README.md index cef05856..46b002c8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Pocket ID is a simple OIDC provider that allows users to authenticate with their → Try out the [Demo](https://demo.pocket-id.org) - + The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases. diff --git a/backend/internal/bootstrap/app_images_bootstrap.go b/backend/internal/bootstrap/app_images_bootstrap.go index 6c428468..fb8d6fe5 100644 --- a/backend/internal/bootstrap/app_images_bootstrap.go +++ b/backend/internal/bootstrap/app_images_bootstrap.go @@ -24,7 +24,8 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) // Previous versions of images // If these are found, they are deleted legacyImageHashes := imageHashMap{ - "background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"), + "background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"), + "background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"), } sourceFiles, err := resources.FS.ReadDir("images") diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 4af260ea..f2229114 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -189,6 +189,7 @@ func initLogger(r *gin.Engine) { "GET /api/application-images/logo", "GET /api/application-images/background", "GET /api/application-images/favicon", + "GET /api/application-images/email", "GET /_app", "GET /fonts", "GET /healthz", diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index e391069d..6f2f828f 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -38,25 +38,26 @@ const ( ) type EnvConfigSchema struct { - AppEnv AppEnv `env:"APP_ENV" options:"toLower"` - LogLevel string `env:"LOG_LEVEL" options:"toLower"` - LogJSON bool `env:"LOG_JSON"` - AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"` - DbProvider DbProvider - DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` - EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` - Port string `env:"PORT"` - Host string `env:"HOST" options:"toLower"` - UnixSocket string `env:"UNIX_SOCKET"` - UnixSocketMode string `env:"UNIX_SOCKET_MODE"` - LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` - UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` - MetricsEnabled bool `env:"METRICS_ENABLED"` - TracingEnabled bool `env:"TRACING_ENABLED"` - TrustProxy bool `env:"TRUST_PROXY"` - AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` - AllowDowngrade bool `env:"ALLOW_DOWNGRADE"` - InternalAppURL string `env:"INTERNAL_APP_URL"` + AppEnv AppEnv `env:"APP_ENV" options:"toLower"` + LogLevel string `env:"LOG_LEVEL" options:"toLower"` + LogJSON bool `env:"LOG_JSON"` + AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"` + AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"` + DbProvider DbProvider + DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` + EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` + Port string `env:"PORT"` + Host string `env:"HOST" options:"toLower"` + UnixSocket string `env:"UNIX_SOCKET"` + UnixSocketMode string `env:"UNIX_SOCKET_MODE"` + LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` + UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` + MetricsEnabled bool `env:"METRICS_ENABLED"` + TracingEnabled bool `env:"TRACING_ENABLED"` + TrustProxy bool `env:"TRUST_PROXY"` + AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` + AllowDowngrade bool `env:"ALLOW_DOWNGRADE"` + InternalAppURL string `env:"INTERNAL_APP_URL"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` @@ -86,15 +87,16 @@ func init() { func defaultConfig() EnvConfigSchema { return EnvConfigSchema{ - AppEnv: AppEnvProduction, - LogLevel: "info", - DbProvider: "sqlite", - FileBackend: "filesystem", - AppURL: AppUrl, - Port: "1411", - Host: "0.0.0.0", - GeoLiteDBPath: "data/GeoLite2-City.mmdb", - GeoLiteDBUrl: MaxMindGeoLiteCityUrl, + AppEnv: AppEnvProduction, + LogLevel: "info", + DbProvider: "sqlite", + FileBackend: "filesystem", + AppURL: AppUrl, + Port: "1411", + Host: "0.0.0.0", + GeoLiteDBPath: "data/GeoLite2-City.mmdb", + GeoLiteDBUrl: MaxMindGeoLiteCityUrl, + AuditLogRetentionDays: 90, } } @@ -191,6 +193,10 @@ func ValidateEnvConfig(config *EnvConfigSchema) error { } + if config.AuditLogRetentionDays <= 0 { + return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0") + } + return nil } diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index 879c0ebc..266e5437 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -129,6 +129,41 @@ func TestParseEnvConfig(t *testing.T) { assert.False(t, EnvConfig.AnalyticsDisabled) }) + t.Run("should default audit log retention days to 90", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("DB_PROVIDER", "sqlite") + t.Setenv("DB_CONNECTION_STRING", "file:test.db") + t.Setenv("APP_URL", "http://localhost:3000") + + err := parseEnvConfig() + require.NoError(t, err) + assert.Equal(t, 90, EnvConfig.AuditLogRetentionDays) + }) + + t.Run("should parse audit log retention days override", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("DB_PROVIDER", "sqlite") + t.Setenv("DB_CONNECTION_STRING", "file:test.db") + t.Setenv("APP_URL", "http://localhost:3000") + t.Setenv("AUDIT_LOG_RETENTION_DAYS", "365") + + err := parseEnvConfig() + require.NoError(t, err) + assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays) + }) + + t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("DB_PROVIDER", "sqlite") + t.Setenv("DB_CONNECTION_STRING", "file:test.db") + t.Setenv("APP_URL", "http://localhost:3000") + t.Setenv("AUDIT_LOG_RETENTION_DAYS", "0") + + err := parseEnvConfig() + require.Error(t, err) + assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0") + }) + t.Run("should parse string environment variables correctly", func(t *testing.T) { EnvConfig = defaultConfig() t.Setenv("DB_CONNECTION_STRING", "postgres://test") diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index c4568377..ec3215aa 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -23,11 +23,13 @@ func NewAppImagesController( } group.GET("/application-images/logo", controller.getLogoHandler) + group.GET("/application-images/email", controller.getEmailLogoHandler) group.GET("/application-images/background", controller.getBackgroundImageHandler) group.GET("/application-images/favicon", controller.getFaviconHandler) group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture) group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler) + group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler) group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler) group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture) @@ -59,6 +61,18 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { c.getImage(ctx, imageName) } +// getEmailLogoHandler godoc +// @Summary Get email logo image +// @Description Get the email logo image for use in emails +// @Tags Application Images +// @Produce image/png +// @Produce image/jpeg +// @Success 200 {file} binary "Email logo image" +// @Router /api/application-images/email [get] +func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) { + c.getImage(ctx, "logoEmail") +} + // getBackgroundImageHandler godoc // @Summary Get background image // @Description Get the background image for the application @@ -124,6 +138,37 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { ctx.Status(http.StatusNoContent) } +// updateEmailLogoHandler godoc +// @Summary Update email logo +// @Description Update the email logo for use in emails +// @Tags Application Images +// @Accept multipart/form-data +// @Param file formData file true "Email logo image file" +// @Success 204 "No Content" +// @Router /api/application-images/email [put] +func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + _ = ctx.Error(err) + return + } + + fileType := utils.GetFileExtension(file.Filename) + mimeType := utils.GetImageMimeType(fileType) + + if mimeType != "image/png" && mimeType != "image/jpeg" { + _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}) + return + } + + if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil { + _ = ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} + // updateBackgroundImageHandler godoc // @Summary Update background image // @Description Update the application background image diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index cf87720e..d348e893 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -72,7 +72,7 @@ type UserController struct { // @Description Retrieve all groups a specific user belongs to // @Tags Users,User Groups // @Param id path string true "User ID" -// @Success 200 {array} dto.UserGroupDtoWithUsers +// @Success 200 {array} dto.UserGroupDto // @Router /api/users/{id}/groups [get] func (uc *UserController) getUserGroupsHandler(c *gin.Context) { userID := c.Param("id") @@ -82,7 +82,7 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) { return } - var groupsDto []dto.UserGroupDtoWithUsers + var groupsDto []dto.UserGroupDto if err := dto.MapStructList(groups, &groupsDto); err != nil { _ = c.Error(err) return @@ -545,7 +545,7 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) { ttl = defaultSignupTokenDuration } - signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit) + signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index ba05b20c..a0567752 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -28,6 +28,7 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A userGroupsGroup.PUT("/:id", ugc.update) userGroupsGroup.DELETE("/:id", ugc.delete) userGroupsGroup.PUT("/:id/users", ugc.updateUsers) + userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients) } } @@ -44,7 +45,7 @@ type UserGroupController struct { // @Param pagination[limit] query int false "Number of items per page" default(20) // @Param sort[column] query string false "Column to sort by" // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") -// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount] +// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto] // @Router /api/user-groups [get] func (ugc *UserGroupController) list(c *gin.Context) { searchTerm := c.Query("search") @@ -57,9 +58,9 @@ func (ugc *UserGroupController) list(c *gin.Context) { } // Map the user groups to DTOs - var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups)) + var groupsDto = make([]dto.UserGroupMinimalDto, len(groups)) for i, group := range groups { - var groupDto dto.UserGroupDtoWithUserCount + var groupDto dto.UserGroupMinimalDto if err := dto.MapStruct(group, &groupDto); err != nil { _ = c.Error(err) return @@ -72,7 +73,7 @@ func (ugc *UserGroupController) list(c *gin.Context) { groupsDto[i] = groupDto } - c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{ + c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{ Data: groupsDto, Pagination: pagination, }) @@ -85,7 +86,7 @@ func (ugc *UserGroupController) list(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "User Group ID" -// @Success 200 {object} dto.UserGroupDtoWithUsers +// @Success 200 {object} dto.UserGroupDto // @Router /api/user-groups/{id} [get] func (ugc *UserGroupController) get(c *gin.Context) { group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id")) @@ -94,7 +95,7 @@ func (ugc *UserGroupController) get(c *gin.Context) { return } - var groupDto dto.UserGroupDtoWithUsers + var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { _ = c.Error(err) return @@ -110,7 +111,7 @@ func (ugc *UserGroupController) get(c *gin.Context) { // @Accept json // @Produce json // @Param userGroup body dto.UserGroupCreateDto true "User group information" -// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group" +// @Success 201 {object} dto.UserGroupDto "Created user group" // @Router /api/user-groups [post] func (ugc *UserGroupController) create(c *gin.Context) { var input dto.UserGroupCreateDto @@ -125,7 +126,7 @@ func (ugc *UserGroupController) create(c *gin.Context) { return } - var groupDto dto.UserGroupDtoWithUsers + var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { _ = c.Error(err) return @@ -142,7 +143,7 @@ func (ugc *UserGroupController) create(c *gin.Context) { // @Produce json // @Param id path string true "User Group ID" // @Param userGroup body dto.UserGroupCreateDto true "User group information" -// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group" +// @Success 200 {object} dto.UserGroupDto "Updated user group" // @Router /api/user-groups/{id} [put] func (ugc *UserGroupController) update(c *gin.Context) { var input dto.UserGroupCreateDto @@ -157,7 +158,7 @@ func (ugc *UserGroupController) update(c *gin.Context) { return } - var groupDto dto.UserGroupDtoWithUsers + var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { _ = c.Error(err) return @@ -192,7 +193,7 @@ func (ugc *UserGroupController) delete(c *gin.Context) { // @Produce json // @Param id path string true "User Group ID" // @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group" -// @Success 200 {object} dto.UserGroupDtoWithUsers +// @Success 200 {object} dto.UserGroupDto // @Router /api/user-groups/{id}/users [put] func (ugc *UserGroupController) updateUsers(c *gin.Context) { var input dto.UserGroupUpdateUsersDto @@ -207,7 +208,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) { return } - var groupDto dto.UserGroupDtoWithUsers + var groupDto dto.UserGroupDto if err := dto.MapStruct(group, &groupDto); err != nil { _ = c.Error(err) return @@ -215,3 +216,35 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) { c.JSON(http.StatusOK, groupDto) } + +// updateAllowedOidcClients godoc +// @Summary Update allowed OIDC clients +// @Description Update the OIDC clients allowed for a specific user group +// @Tags OIDC +// @Accept json +// @Produce json +// @Param id path string true "User Group ID" +// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow" +// @Success 200 {object} dto.UserGroupDto "Updated user group" +// @Router /api/user-groups/{id}/allowed-oidc-clients [put] +func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) { + var input dto.UserGroupUpdateAllowedOidcClientsDto + if err := c.ShouldBindJSON(&input); err != nil { + _ = c.Error(err) + return + } + + userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input) + if err != nil { + _ = c.Error(err) + return + } + + var userGroupDto dto.UserGroupDto + if err := dto.MapStruct(userGroup, &userGroupDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, userGroupDto) +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 9f3239de..da26686a 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -18,11 +18,12 @@ type OidcClientDto struct { IsPublic bool `json:"isPublic"` PkceEnabled bool `json:"pkceEnabled"` Credentials OidcClientCredentialsDto `json:"credentials"` + IsGroupRestricted bool `json:"isGroupRestricted"` } type OidcClientWithAllowedUserGroupsDto struct { OidcClientDto - AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` + AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"` } type OidcClientWithAllowedGroupsCountDto struct { @@ -43,6 +44,7 @@ type OidcClientUpdateDto struct { HasDarkLogo bool `json:"hasDarkLogo"` LogoURL *string `json:"logoUrl"` DarkLogoURL *string `json:"darkLogoUrl"` + IsGroupRestricted bool `json:"isGroupRestricted"` } type OidcClientCreateDto struct { diff --git a/backend/internal/dto/signup_token_dto.go b/backend/internal/dto/signup_token_dto.go index 92bb374a..bffd430e 100644 --- a/backend/internal/dto/signup_token_dto.go +++ b/backend/internal/dto/signup_token_dto.go @@ -6,15 +6,17 @@ import ( ) type SignupTokenCreateDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` - UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` + TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` + UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` + UserGroupIDs []string `json:"userGroupIds"` } type SignupTokenDto struct { - ID string `json:"id"` - Token string `json:"token"` - ExpiresAt datatype.DateTime `json:"expiresAt"` - UsageLimit int `json:"usageLimit"` - UsageCount int `json:"usageCount"` - CreatedAt datatype.DateTime `json:"createdAt"` + ID string `json:"id"` + Token string `json:"token"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + UsageLimit int `json:"usageLimit"` + UsageCount int `json:"usageCount"` + UserGroups []UserGroupMinimalDto `json:"userGroups"` + CreatedAt datatype.DateTime `json:"createdAt"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 985b12d7..671142f5 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -8,30 +8,31 @@ import ( ) type UserDto struct { - ID string `json:"id"` - Username string `json:"username"` - Email *string `json:"email" ` - FirstName string `json:"firstName"` - LastName *string `json:"lastName"` - DisplayName string `json:"displayName"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - CustomClaims []CustomClaimDto `json:"customClaims"` - UserGroups []UserGroupDto `json:"userGroups"` - LdapID *string `json:"ldapId"` - Disabled bool `json:"disabled"` + ID string `json:"id"` + Username string `json:"username"` + Email *string `json:"email" ` + FirstName string `json:"firstName"` + LastName *string `json:"lastName"` + DisplayName string `json:"displayName"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + CustomClaims []CustomClaimDto `json:"customClaims"` + UserGroups []UserGroupMinimalDto `json:"userGroups"` + LdapID *string `json:"ldapId"` + Disabled bool `json:"disabled"` } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - Disabled bool `json:"disabled"` - LdapID string `json:"-"` + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + Disabled bool `json:"disabled"` + UserGroupIds []string `json:"userGroupIds"` + LdapID string `json:"-"` } func (u UserCreateDto) Validate() error { diff --git a/backend/internal/dto/user_group_dto.go b/backend/internal/dto/user_group_dto.go index 09b3d1dc..79cd8d48 100644 --- a/backend/internal/dto/user_group_dto.go +++ b/backend/internal/dto/user_group_dto.go @@ -8,25 +8,17 @@ import ( ) type UserGroupDto struct { - ID string `json:"id"` - FriendlyName string `json:"friendlyName"` - Name string `json:"name"` - CustomClaims []CustomClaimDto `json:"customClaims"` - LdapID *string `json:"ldapId"` - CreatedAt datatype.DateTime `json:"createdAt"` + ID string `json:"id"` + FriendlyName string `json:"friendlyName"` + Name string `json:"name"` + CustomClaims []CustomClaimDto `json:"customClaims"` + LdapID *string `json:"ldapId"` + CreatedAt datatype.DateTime `json:"createdAt"` + Users []UserDto `json:"users"` + AllowedOidcClients []OidcClientMetaDataDto `json:"allowedOidcClients"` } -type UserGroupDtoWithUsers struct { - ID string `json:"id"` - FriendlyName string `json:"friendlyName"` - Name string `json:"name"` - CustomClaims []CustomClaimDto `json:"customClaims"` - Users []UserDto `json:"users"` - LdapID *string `json:"ldapId"` - CreatedAt datatype.DateTime `json:"createdAt"` -} - -type UserGroupDtoWithUserCount struct { +type UserGroupMinimalDto struct { ID string `json:"id"` FriendlyName string `json:"friendlyName"` Name string `json:"name"` @@ -36,6 +28,10 @@ type UserGroupDtoWithUserCount struct { CreatedAt datatype.DateTime `json:"createdAt"` } +type UserGroupUpdateAllowedOidcClientsDto struct { + OidcClientIDs []string `json:"oidcClientIds" binding:"required"` +} + type UserGroupCreateDto struct { FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 3e89e886..3566f858 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -10,6 +10,7 @@ import ( "github.com/go-co-op/gocron/v2" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" ) @@ -119,11 +120,13 @@ func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error { return nil } -// ClearAuditLogs deletes audit logs older than 90 days +// ClearAuditLogs deletes audit logs older than the configured retention window func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { + cutoff := time.Now().AddDate(0, 0, -common.EnvConfig.AuditLogRetentionDays) + st := j.db. WithContext(ctx). - Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))) + Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff)) if st.Error != nil { return fmt.Errorf("failed to delete old audit logs: %w", st.Error) } diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 1aaebe91..0902d125 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -58,6 +58,7 @@ type OidcClient struct { RequiresReauthentication bool `sortable:"true" filterable:"true"` Credentials OidcClientCredentials LaunchURL *string + IsGroupRestricted bool AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` CreatedByID *string diff --git a/backend/internal/model/signup_token.go b/backend/internal/model/signup_token.go index af1599aa..6ebbe9c1 100644 --- a/backend/internal/model/signup_token.go +++ b/backend/internal/model/signup_token.go @@ -13,6 +13,7 @@ type SignupToken struct { ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` UsageLimit int `json:"usageLimit" sortable:"true"` UsageCount int `json:"usageCount" sortable:"true"` + UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"` } func (st *SignupToken) IsExpired() bool { diff --git a/backend/internal/model/user_group.go b/backend/internal/model/user_group.go index caa670be..99a750da 100644 --- a/backend/internal/model/user_group.go +++ b/backend/internal/model/user_group.go @@ -2,9 +2,10 @@ package model type UserGroup struct { Base - FriendlyName string `sortable:"true"` - Name string `sortable:"true"` - LdapID *string - Users []User `gorm:"many2many:user_groups_users;"` - CustomClaims []CustomClaim + FriendlyName string `sortable:"true"` + Name string `sortable:"true"` + LdapID *string + Users []User `gorm:"many2many:user_groups_users;"` + CustomClaims []CustomClaim + AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"` } diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index c15f4d61..830be42b 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -169,10 +169,11 @@ func (s *TestService) SeedDatabase(baseURL string) error { Base: model.Base{ ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018", }, - Name: "Immich", - Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x - CallbackURLs: model.UrlList{"http://immich/auth/callback"}, - CreatedByID: utils.Ptr(users[1].ID), + Name: "Immich", + Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x + CallbackURLs: model.UrlList{"http://immich/auth/callback"}, + CreatedByID: utils.Ptr(users[1].ID), + IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{ userGroups[1], }, @@ -185,6 +186,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo CallbackURLs: model.UrlList{"http://tailscale/auth/callback"}, LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"}, + IsGroupRestricted: true, CreatedByID: utils.Ptr(users[0].ID), }, { @@ -349,6 +351,9 @@ func (s *TestService) SeedDatabase(baseURL string) error { ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), UsageLimit: 1, UsageCount: 0, + UserGroups: []model.UserGroup{ + userGroups[0], + }, }, { Base: model.Base{ diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 3f0a7e30..05affa5b 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr data := &email.TemplateData[V]{ AppName: dbConfig.AppName.Value, - LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo", + LogoURL: common.EnvConfig.AppURL + "/api/application-images/email", Data: tData, } diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 47c624c1..69b4c08b 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -225,7 +225,7 @@ func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, // IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool { - if len(client.AllowedUserGroups) == 0 { + if !client.IsGroupRestricted { return true } @@ -777,6 +777,14 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d updateOIDCClientModelFromDto(&client, &input) + if !input.IsGroupRestricted { + // Clear allowed user groups if the restriction is removed + err = tx.Model(&client).Association("AllowedUserGroups").Clear() + if err != nil { + return model.OidcClient{}, err + } + } + err = tx.WithContext(ctx).Save(&client).Error if err != nil { return model.OidcClient{}, err @@ -815,6 +823,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.PkceEnabled = input.IsPublic || input.PkceEnabled client.RequiresReauthentication = input.RequiresReauthentication client.LaunchURL = input.LaunchURL + client.IsGroupRestricted = input.IsGroupRestricted // Credentials client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities)) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index f18a66c9..c9223ae5 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -53,6 +53,7 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm. Where("id = ?", id). Preload("CustomClaims"). Preload("Users"). + Preload("AllowedOidcClients"). First(&group). Error return group, err @@ -248,3 +249,54 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) ( Count() return count, nil } + +func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id string, input dto.UserGroupUpdateAllowedOidcClientsDto) (group model.UserGroup, err error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + group, err = s.getInternal(ctx, id, tx) + if err != nil { + return model.UserGroup{}, err + } + + // Fetch the clients based on the client IDs + var clients []model.OidcClient + if len(input.OidcClientIDs) > 0 { + err = tx. + WithContext(ctx). + Where("id IN (?)", input.OidcClientIDs). + Find(&clients). + Error + if err != nil { + return model.UserGroup{}, err + } + } + + // Replace the current clients with the new set of clients + err = tx. + WithContext(ctx). + Model(&group). + Association("AllowedOidcClients"). + Replace(clients) + if err != nil { + return model.UserGroup{}, err + } + + // Save the updated group + err = tx. + WithContext(ctx). + Save(&group). + Error + if err != nil { + return model.UserGroup{}, err + } + + err = tx.Commit().Error + if err != nil { + return model.UserGroup{}, err + } + + return group, nil +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 260e9778..315de043 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -253,6 +253,18 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea return model.User{}, &common.UserEmailNotSetError{} } + var userGroups []model.UserGroup + if len(input.UserGroupIds) > 0 { + err := tx. + WithContext(ctx). + Where("id IN ?", input.UserGroupIds). + Find(&userGroups). + Error + if err != nil { + return model.User{}, err + } + } + user := model.User{ FirstName: input.FirstName, LastName: input.LastName, @@ -262,6 +274,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea IsAdmin: input.IsAdmin, Locale: input.Locale, Disabled: input.Disabled, + UserGroups: userGroups, } if input.LdapID != "" { user.LdapID = &input.LdapID @@ -285,7 +298,13 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea // Apply default groups and claims for new non-LDAP users if !isLdapSync { - if err := s.applySignupDefaults(ctx, &user, tx); err != nil { + if len(input.UserGroupIds) == 0 { + if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { + return model.User{}, err + } + } + + if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { return model.User{}, err } } @@ -293,10 +312,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea return user, nil } -func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error { +func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error { config := s.appConfigService.GetDbConfig() - // Apply default user groups var groupIDs []string v := config.SignupDefaultUserGroupIDs.Value if v != "" && v != "[]" { @@ -323,10 +341,14 @@ func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, } } } + return nil +} + +func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { + config := s.appConfigService.GetDbConfig() - // Apply default custom claims var claims []dto.CustomClaimCreateDto - v = config.SignupDefaultCustomClaims.Value + v := config.SignupDefaultCustomClaims.Value if v != "" && v != "[]" { err := json.Unmarshal([]byte(v), &claims) if err != nil { @@ -727,12 +749,22 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user Error } -func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) { +func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { signupToken, err := NewSignupToken(ttl, usageLimit) if err != nil { return model.SignupToken{}, err } + var userGroups []model.UserGroup + err = s.db.WithContext(ctx). + Where("id IN ?", userGroupIDs). + Find(&userGroups). + Error + if err != nil { + return model.SignupToken{}, err + } + signupToken.UserGroups = userGroups + err = s.db.WithContext(ctx).Create(signupToken).Error if err != nil { return model.SignupToken{}, err @@ -755,9 +787,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd } var signupToken model.SignupToken + var userGroupIDs []string if tokenProvided { err := tx. WithContext(ctx). + Preload("UserGroups"). Where("token = ?", signupData.Token). Clauses(clause.Locking{Strength: "UPDATE"}). First(&signupToken). @@ -772,14 +806,19 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd if !signupToken.IsValid() { return model.User{}, "", &common.TokenInvalidOrExpiredError{} } + + for _, group := range signupToken.UserGroups { + userGroupIDs = append(userGroupIDs, group.ID) + } } userToCreate := dto.UserCreateDto{ - Username: signupData.Username, - Email: signupData.Email, - FirstName: signupData.FirstName, - LastName: signupData.LastName, - DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), + Username: signupData.Username, + Email: signupData.Email, + FirstName: signupData.FirstName, + LastName: signupData.LastName, + DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), + UserGroupIds: userGroupIDs, } user, err := s.createUserInternal(ctx, userToCreate, false, tx) @@ -820,7 +859,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) { var tokens []model.SignupToken - query := s.db.WithContext(ctx).Model(&model.SignupToken{}) + query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{}) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens) return tokens, pagination, err diff --git a/backend/resources/images/background.webp b/backend/resources/images/background.webp index 7950cac6..6c48fa1f 100644 Binary files a/backend/resources/images/background.webp and b/backend/resources/images/background.webp differ diff --git a/backend/resources/images/logoEmail.png b/backend/resources/images/logoEmail.png new file mode 100644 index 00000000..ab7bff23 Binary files /dev/null and b/backend/resources/images/logoEmail.png differ diff --git a/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql new file mode 100644 index 00000000..9db5aae4 --- /dev/null +++ b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql @@ -0,0 +1 @@ +DROP TABLE signup_tokens_user_groups; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql new file mode 100644 index 00000000..9d7b4175 --- /dev/null +++ b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE signup_tokens_user_groups +( + signup_token_id UUID NOT NULL, + user_group_id UUID NOT NULL, + PRIMARY KEY (signup_token_id, user_group_id), + FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.down.sql b/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.down.sql new file mode 100644 index 00000000..f535c59b --- /dev/null +++ b/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.down.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients DROP COLUMN is_group_restricted; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.up.sql b/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.up.sql new file mode 100644 index 00000000..63392cbf --- /dev/null +++ b/backend/resources/migrations/postgres/20251219000000_oidc_client_group_restriction.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE oidc_clients + ADD COLUMN is_group_restricted boolean NOT NULL DEFAULT false; + +UPDATE oidc_clients oc +SET is_group_restricted = + EXISTS ( + SELECT 1 + FROM oidc_clients_allowed_user_groups a + WHERE a.oidc_client_id = oc.id + ); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql index e8dba6e4..1c556747 100644 --- a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql +++ b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql @@ -1 +1,7 @@ -ALTER TABLE one_time_access_tokens DROP COLUMN device_token; \ No newline at end of file +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE one_time_access_tokens DROP COLUMN device_token; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql index 1aabc368..f563e91f 100644 --- a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql +++ b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql @@ -1 +1,7 @@ -ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT; \ No newline at end of file +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql new file mode 100644 index 00000000..9bf54307 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +DROP TABLE signup_tokens_user_groups; + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql new file mode 100644 index 00000000..b411a4a2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql @@ -0,0 +1,14 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +CREATE TABLE signup_tokens_user_groups +( + signup_token_id TEXT NOT NULL, + user_group_id TEXT NOT NULL, + PRIMARY KEY (signup_token_id, user_group_id), + FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE +); + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.down.sql b/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.down.sql new file mode 100644 index 00000000..6e198fc7 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.down.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE oidc_clients DROP COLUMN is_group_restricted; + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.up.sql b/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.up.sql new file mode 100644 index 00000000..21dee24d --- /dev/null +++ b/backend/resources/migrations/sqlite/20251219000000_oidc_client_group_restriction.up.sql @@ -0,0 +1,13 @@ +PRAGMA foreign_keys= OFF; +BEGIN; + +ALTER TABLE oidc_clients + ADD COLUMN is_group_restricted BOOLEAN NOT NULL DEFAULT 0; + +UPDATE oidc_clients +SET is_group_restricted = (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END + FROM oidc_clients_allowed_user_groups + WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id); + +COMMIT; +PRAGMA foreign_keys= ON; \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fa05691f..50a74506 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -95,7 +95,7 @@ "settings": "Settings", "update_pocket_id": "Update Pocket ID", "powered_by": "Powered by", - "see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.", + "see_your_recent_account_activities": "See your account activities within the configured retention period.", "time": "Time", "event": "Event", "approximate_location": "Approximate Location", @@ -301,16 +301,21 @@ "are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.", "generate": "Generate", "new_client_secret_created_successfully": "New client secret created successfully", - "allowed_user_groups_updated_successfully": "Allowed user groups updated successfully", "oidc_client_name": "OIDC Client {name}", "client_id": "Client ID", "client_secret": "Client secret", "show_more_details": "Show more details", "allowed_user_groups": "Allowed User Groups", - "add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.", + "allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.", + "allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.", + "unrestrict": "Unrestrict", + "restrict": "Restrict", + "user_groups_restriction_updated_successfully": "User groups restriction updated successfully", + "allowed_user_groups_updated_successfully": "Allowed user groups updated successfully", "favicon": "Favicon", "light_mode_logo": "Light Mode Logo", "dark_mode_logo": "Dark Mode Logo", + "email_logo": "Email Logo", "background_image": "Background Image", "language": "Language", "reset_profile_picture_question": "Reset profile picture?", @@ -327,7 +332,7 @@ "all_clients": "All Clients", "all_locations": "All Locations", "global_audit_log": "Global Audit Log", - "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", + "see_all_recent_account_activities": "View the account activities of all users during the set retention period.", "token_sign_in": "Token Sign In", "client_authorization": "Client Authorization", "new_client_authorization": "New Client Authorization", @@ -469,5 +474,11 @@ "default_profile_picture": "Default Profile Picture", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token.", + "allowed_oidc_clients": "Allowed OIDC Clients", + "allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.", + "unrestrict_oidc_client": "Unrestrict {clientName}", + "confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client {clientName}? This will remove all group assignments for this client and any user will be able to sign in.", + "allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully" } diff --git a/frontend/src/app.css b/frontend/src/app.css index d120917c..361a0230 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -232,22 +232,19 @@ } } -@keyframes slide-bg-container { +@keyframes bg-zoom { 0% { - left: 0; + transform: scale(1.3); } 100% { - left: 650px; + transform: scale(1); } } -.animate-slide-bg-container { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - animation: slide-bg-container 0.6s cubic-bezier(0.33, 1, 0.68, 1) forwards; +.animate-bg-zoom { + transform-origin: center; + will-change: transform; + animation: bg-zoom 0.7s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; } @keyframes delayed-fade { diff --git a/frontend/src/lib/components/collapsible-card.svelte b/frontend/src/lib/components/collapsible-card.svelte index d8fc045a..94358651 100644 --- a/frontend/src/lib/components/collapsible-card.svelte +++ b/frontend/src/lib/components/collapsible-card.svelte @@ -12,6 +12,8 @@ title, description, defaultExpanded = false, + forcedExpanded, + button, icon, children }: { @@ -19,7 +21,9 @@ title: string; description?: string; defaultExpanded?: boolean; + forcedExpanded?: boolean; icon?: typeof IconType; + button?: Snippet; children: Snippet; } = $props(); @@ -47,6 +51,12 @@ } loadExpandedState(); }); + + $effect(() => { + if (forcedExpanded !== undefined) { + expanded = forcedExpanded; + } + }); @@ -63,11 +73,18 @@ {description} {/if} - + {#if button} + {@render button()} + {:else} + + {/if} {#if expanded} diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index e7f09e9a..e3dbaae9 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -9,6 +9,17 @@ import type { HTMLAttributes } from 'svelte/elements'; import FormattedMessage from '../formatted-message.svelte'; + type WithoutChildren = { + children?: undefined; + input?: FormInput; + labelFor?: never; + }; + type WithChildren = { + children: Snippet; + input?: any; + labelFor?: string; + }; + let { input = $bindable(), label, @@ -19,25 +30,25 @@ type = 'text', children, onInput, + labelFor, ...restProps - }: HTMLAttributes & { - input?: FormInput; - label?: string; - description?: string; - docsLink?: string; - placeholder?: string; - disabled?: boolean; - type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; - onInput?: (e: FormInputEvent) => void; - children?: Snippet; - } = $props(); + }: HTMLAttributes & + (WithChildren | WithoutChildren) & { + label?: string; + description?: string; + docsLink?: string; + placeholder?: string; + disabled?: boolean; + type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; + onInput?: (e: FormInputEvent) => void; + } = $props(); const id = label?.toLowerCase().replace(/ /g, '-');
{#if label} - + {/if} {#if description}

diff --git a/frontend/src/lib/components/form/user-group-input.svelte b/frontend/src/lib/components/form/user-group-input.svelte new file mode 100644 index 00000000..74e04eda --- /dev/null +++ b/frontend/src/lib/components/form/user-group-input.svelte @@ -0,0 +1,50 @@ + + + onUserGroupSearch(e.currentTarget.value)} + selectedItems={selectedGroupIds} + onSelect={(selected) => (selectedGroupIds = selected)} + {isLoading} + disableInternalSearch +/> diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte index 503e1ca1..82082ccc 100644 --- a/frontend/src/lib/components/header/header.svelte +++ b/frontend/src/lib/components/header/header.svelte @@ -19,7 +19,7 @@ ); -

+
-
+
{@render children()}
{#if showAlternativeSignInMethodButton} -
+ - -
+ +
{m.login_background()}
@@ -89,7 +85,7 @@ class="flex h-screen items-center justify-center bg-cover bg-center text-center" style="background-image: url({cachedBackgroundImage.getUrl()});" > - + diff --git a/frontend/src/lib/components/signup/signup-token-list-modal.svelte b/frontend/src/lib/components/signup/signup-token-list-modal.svelte index c1581694..98ce1caf 100644 --- a/frontend/src/lib/components/signup/signup-token-list-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-list-modal.svelte @@ -11,7 +11,7 @@ AdvancedTableColumn, CreateAdvancedTableActions } from '$lib/types/advanced-table.type'; - import type { SignupTokenDto } from '$lib/types/signup-token.type'; + import type { SignupToken } from '$lib/types/signup-token.type'; import { axiosErrorToast } from '$lib/utils/error-util'; import { Copy, Trash2 } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; @@ -23,14 +23,14 @@ } = $props(); const userService = new UserService(); - let tableRef: AdvancedTable; + let tableRef: AdvancedTable; function formatDate(dateStr: string | undefined) { if (!dateStr) return m.never(); return new Date(dateStr).toLocaleString(); } - async function deleteToken(token: SignupTokenDto) { + async function deleteToken(token: SignupToken) { openConfirmDialog({ title: m.delete_signup_token(), message: m.are_you_sure_you_want_to_delete_this_signup_token(), @@ -58,11 +58,11 @@ return new Date(expiresAt) < new Date(); } - function isTokenUsedUp(token: SignupTokenDto) { + function isTokenUsedUp(token: SignupToken) { return token.usageCount >= token.usageLimit; } - function getTokenStatus(token: SignupTokenDto) { + function getTokenStatus(token: SignupToken) { if (isTokenExpired(token.expiresAt)) return 'expired'; if (isTokenUsedUp(token)) return 'used-up'; return 'active'; @@ -79,7 +79,7 @@ } } - function copySignupLink(token: SignupTokenDto) { + function copySignupLink(token: SignupToken) { const signupLink = `${page.url.origin}/st/${token.token}`; navigator.clipboard .writeText(signupLink) @@ -91,7 +91,7 @@ }); } - const columns: AdvancedTableColumn[] = [ + const columns: AdvancedTableColumn[] = [ { label: m.token(), column: 'token', cell: TokenCell }, { label: m.status(), key: 'status', cell: StatusCell }, { @@ -106,7 +106,12 @@ sortable: true, value: (item) => formatDate(item.expiresAt) }, - { label: 'Usage Limit', column: 'usageLimit' }, + { + key: 'userGroups', + label: m.user_groups(), + value: (item) => item.userGroups.map((g) => g.name).join(', '), + hidden: true + }, { label: m.created(), column: 'createdAt', @@ -116,7 +121,7 @@ } ]; - const actions: CreateAdvancedTableActions = (_) => [ + const actions: CreateAdvancedTableActions = (_) => [ { label: m.copy(), icon: Copy, @@ -131,13 +136,13 @@ ]; -{#snippet TokenCell({ item }: { item: SignupTokenDto })} +{#snippet TokenCell({ item }: { item: SignupToken })} {item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))} {/snippet} -{#snippet StatusCell({ item }: { item: SignupTokenDto })} +{#snippet StatusCell({ item }: { item: SignupToken })} {@const status = getTokenStatus(item)} {@const statusBadge = getStatusBadge(status)} @@ -145,7 +150,7 @@ {/snippet} -{#snippet UsageCell({ item }: { item: SignupTokenDto })} +{#snippet UsageCell({ item }: { item: SignupToken })}
{item.usageCount} {m.of()} diff --git a/frontend/src/lib/components/signup/signup-token-modal.svelte b/frontend/src/lib/components/signup/signup-token-modal.svelte index fc948465..33e88e1d 100644 --- a/frontend/src/lib/components/signup/signup-token-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-modal.svelte @@ -1,16 +1,22 @@ @@ -66,49 +129,57 @@ {#if signupToken === null} -
-
- +
+ (selectedExpiration = v! as keyof typeof availableExpirations)} + value={$inputs.ttl.value.toString()} + onValueChange={(v) => v && form.setValue('ttl', Number(v))} > - {selectedExpiration} + {getExpirationLabel($inputs.ttl.value)} - {#each Object.keys(availableExpirations) as key} - {key} + {#each availableExpirations as expiration} + + {expiration.label} + {/each} -
- -
- -

- {m.number_of_times_token_can_be_used()} -

+ {#if $inputs.ttl.error} +

{$inputs.ttl.error}

+ {/if} + + -
-
- - - - + + + + + + + {:else}
-

{m.usage_limit()}: {usageLimit}

-

{m.expiration()}: {selectedExpiration}

+

{m.usage_limit()}: {createdSignupData?.usageLimit}

+

{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}

{/if} diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte index f67a4d59..1e2eb1bd 100644 --- a/frontend/src/lib/components/table/advanced-table.svelte +++ b/frontend/src/lib/components/table/advanced-table.svelte @@ -25,6 +25,7 @@ selectedIds = $bindable(), withoutSearch = false, selectionDisabled = false, + rowSelectionDisabled, fetchCallback, defaultSort, columns, @@ -34,6 +35,7 @@ selectedIds?: string[]; withoutSearch?: boolean; selectionDisabled?: boolean; + rowSelectionDisabled?: (item: T) => boolean; fetchCallback: (requestOptions: ListRequestOptions) => Promise>; defaultSort?: SortRequest; columns: AdvancedTableColumn[]; @@ -91,7 +93,9 @@ }); async function onAllCheck(checked: boolean) { - const pageIds = items!.data.map((item) => item.id); + const pageIds = items!.data + .filter((item) => !rowSelectionDisabled?.(item)) + .map((item) => item.id); const current = selectedIds ?? []; if (checked) { @@ -264,7 +268,7 @@ {#if selectedIds} onCheck(c, item.id)} /> diff --git a/frontend/src/lib/components/user-group-selection.svelte b/frontend/src/lib/components/user-group-selection.svelte index 19eeb0bc..81540d7a 100644 --- a/frontend/src/lib/components/user-group-selection.svelte +++ b/frontend/src/lib/components/user-group-selection.svelte @@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages'; import UserGroupService from '$lib/services/user-group-service'; import type { AdvancedTableColumn } from '$lib/types/advanced-table.type'; - import type { UserGroupWithUserCount } from '$lib/types/user-group.type'; + import type { UserGroupMinimal } from '$lib/types/user-group.type'; let { selectionDisabled = false, @@ -15,7 +15,7 @@ const userGroupService = new UserGroupService(); - const columns: AdvancedTableColumn[] = [ + const columns: AdvancedTableColumn[] = [ { label: 'ID', column: 'id', hidden: true }, { label: m.friendly_name(), column: 'friendlyName', sortable: true }, { label: m.name(), column: 'name', sortable: true }, diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 1e8e7f64..88386bf6 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -4,6 +4,7 @@ import { cachedApplicationLogo, cachedBackgroundImage, cachedDefaultProfilePicture, + cachedEmailLogo, cachedProfilePicture } from '$lib/utils/cached-image-util'; import { get } from 'svelte/store'; @@ -46,6 +47,14 @@ export default class AppConfigService extends APIService { cachedApplicationLogo.bustCache(light); }; + updateEmailLogo = async (emailLogo: File) => { + const formData = new FormData(); + formData.append('file', emailLogo); + + await this.api.put(`/application-images/email`, formData); + cachedEmailLogo.bustCache(); + }; + updateDefaultProfilePicture = async (defaultProfilePicture: File) => { const formData = new FormData(); formData.append('file', defaultProfilePicture); diff --git a/frontend/src/lib/services/user-group-service.ts b/frontend/src/lib/services/user-group-service.ts index d10e45c9..b442ba71 100644 --- a/frontend/src/lib/services/user-group-service.ts +++ b/frontend/src/lib/services/user-group-service.ts @@ -1,30 +1,26 @@ import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; -import type { - UserGroupCreate, - UserGroupWithUserCount, - UserGroupWithUsers -} from '$lib/types/user-group.type'; +import type { UserGroup, UserGroupCreate, UserGroupMinimal } from '$lib/types/user-group.type'; import APIService from './api-service'; export default class UserGroupService extends APIService { list = async (options?: ListRequestOptions) => { const res = await this.api.get('/user-groups', { params: options }); - return res.data as Paginated; + return res.data as Paginated; }; get = async (id: string) => { const res = await this.api.get(`/user-groups/${id}`); - return res.data as UserGroupWithUsers; + return res.data as UserGroup; }; create = async (user: UserGroupCreate) => { const res = await this.api.post('/user-groups', user); - return res.data as UserGroupWithUsers; + return res.data as UserGroup; }; update = async (id: string, user: UserGroupCreate) => { const res = await this.api.put(`/user-groups/${id}`, user); - return res.data as UserGroupWithUsers; + return res.data as UserGroup; }; remove = async (id: string) => { @@ -33,6 +29,11 @@ export default class UserGroupService extends APIService { updateUsers = async (id: string, userIds: string[]) => { const res = await this.api.put(`/user-groups/${id}/users`, { userIds }); - return res.data as UserGroupWithUsers; + return res.data as UserGroup; + }; + + updateAllowedOidcClients = async (id: string, oidcClientIds: string[]) => { + const res = await this.api.put(`/user-groups/${id}/allowed-oidc-clients`, { oidcClientIds }); + return res.data as UserGroup; }; } diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index c53f4c4f..9f3163f0 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -1,6 +1,6 @@ import userStore from '$lib/stores/user-store'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; -import type { SignupTokenDto } from '$lib/types/signup-token.type'; +import type { SignupToken } from '$lib/types/signup-token.type'; import type { UserGroup } from '$lib/types/user-group.type'; import type { User, UserCreate, UserSignUp } from '$lib/types/user.type'; import { cachedProfilePicture } from '$lib/utils/cached-image-util'; @@ -76,8 +76,12 @@ export default class UserService extends APIService { return res.data.token; }; - createSignupToken = async (ttl: string | number, usageLimit: number) => { - const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit }); + createSignupToken = async ( + ttl: string | number, + usageLimit: number, + userGroupIds: string[] = [] + ) => { + const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds }); return res.data.token; }; @@ -111,7 +115,7 @@ export default class UserService extends APIService { listSignupTokens = async (options?: ListRequestOptions) => { const res = await this.api.get('/signup-tokens', { params: options }); - return res.data as Paginated; + return res.data as Paginated; }; deleteSignupToken = async (tokenId: string) => { diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 9c926fbb..b0b37ed8 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & { requiresReauthentication: boolean; credentials?: OidcClientCredentials; launchURL?: string; + isGroupRestricted: boolean; }; export type OidcClientWithAllowedUserGroups = OidcClient & { diff --git a/frontend/src/lib/types/signup-token.type.ts b/frontend/src/lib/types/signup-token.type.ts index 1212f478..2d7206ff 100644 --- a/frontend/src/lib/types/signup-token.type.ts +++ b/frontend/src/lib/types/signup-token.type.ts @@ -1,8 +1,11 @@ -export interface SignupTokenDto { +import type { UserGroup } from './user-group.type'; + +export interface SignupToken { id: string; token: string; expiresAt: string; usageLimit: number; usageCount: number; + userGroups: UserGroup[]; createdAt: string; } diff --git a/frontend/src/lib/types/user-group.type.ts b/frontend/src/lib/types/user-group.type.ts index 83067744..2d977d73 100644 --- a/frontend/src/lib/types/user-group.type.ts +++ b/frontend/src/lib/types/user-group.type.ts @@ -1,4 +1,5 @@ import type { CustomClaim } from './custom-claim.type'; +import type { OidcClientMetaData } from './oidc.type'; import type { User } from './user.type'; export type UserGroup = { @@ -8,13 +9,11 @@ export type UserGroup = { createdAt: string; customClaims: CustomClaim[]; ldapId?: string; -}; - -export type UserGroupWithUsers = UserGroup & { users: User[]; + allowedOidcClients: OidcClientMetaData[]; }; -export type UserGroupWithUserCount = UserGroup & { +export type UserGroupMinimal = Omit & { userCount: number; }; diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index bb732bd1..4f883867 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -20,6 +20,11 @@ export const cachedApplicationLogo: CachableImage = { } }; +export const cachedEmailLogo: CachableImage = { + getUrl: () => getCachedImageUrl(new URL('/api/application-images/email', window.location.origin)), + bustCache: () => bustImageCache(new URL('/api/application-images/email', window.location.origin)) +}; + export const cachedDefaultProfilePicture: CachableImage = { getUrl: () => getCachedImageUrl( diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 24baaee3..597cc791 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -9,6 +9,7 @@ import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { startAuthentication } from '@simplewebauthn/browser'; import { fade } from 'svelte/transition'; + import { cn } from 'tailwind-variants'; import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte'; let { data } = $props(); @@ -55,13 +56,18 @@ {m.authenticate_with_passkey_to_access_account()}

{/if} -
+
{#if $appConfigStore.allowUserSignups === 'open'} - {/if} -
diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index 05566b01..fdecb945 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -42,6 +42,7 @@ async function updateImages( logoLight: File | undefined, logoDark: File | undefined, + logoEmail: File | undefined, defaultProfilePicture: File | null | undefined, backgroundImage: File | undefined, favicon: File | undefined @@ -56,6 +57,10 @@ ? appConfigService.updateLogo(logoDark, false) : Promise.resolve(); + const emailLogoPromise = logoEmail + ? appConfigService.updateEmailLogo(logoEmail) + : Promise.resolve(); + const defaultProfilePicturePromise = defaultProfilePicture === null ? appConfigService.deleteDefaultProfilePicture() @@ -70,6 +75,7 @@ await Promise.all([ lightLogoPromise, darkLogoPromise, + emailLogoPromise, defaultProfilePicturePromise, backgroundImagePromise, faviconPromise diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte index ad4c0bde..33e81896 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte @@ -1,16 +1,13 @@
@@ -152,17 +111,7 @@

{m.user_creation_groups_description()}

- onUserGroupSearch(e.currentTarget.value)} - selectedItems={selectedGroups.map((g) => g.value)} - onSelect={(selected) => { - selectedGroups = userGroups.filter((g) => selected.includes(g.value)); - }} - isLoading={isUserSearchLoading} - disableInternalSearch - /> +
diff --git a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte index 8fced2f7..e59795ee 100644 --- a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte @@ -4,7 +4,8 @@ import { cachedApplicationLogo, cachedBackgroundImage, - cachedDefaultProfilePicture + cachedDefaultProfilePicture, + cachedEmailLogo } from '$lib/utils/cached-image-util'; import ApplicationImage from './application-image.svelte'; @@ -14,6 +15,7 @@ callback: ( logoLight: File | undefined, logoDark: File | undefined, + logoEmail: File | undefined, defaultProfilePicture: File | null | undefined, backgroundImage: File | undefined, favicon: File | undefined @@ -22,6 +24,7 @@ let logoLight = $state(); let logoDark = $state(); + let logoEmail = $state(); let defaultProfilePicture = $state(); let backgroundImage = $state(); let favicon = $state(); @@ -54,6 +57,15 @@ imageURL={cachedApplicationLogo.getUrl(false)} forceColorScheme="dark" /> + callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)} + onclick={() => + callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)} >{m.save()}
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index a90bafab..857bd155 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -80,6 +80,44 @@ return success; } + async function enableGroupRestriction() { + client.isGroupRestricted = true; + await oidcService + .updateClient(client.id, { + ...client, + isGroupRestricted: true + }) + .then(() => { + toast.success(m.user_groups_restriction_updated_successfully()); + client.isGroupRestricted = true; + }) + .catch(axiosErrorToast); + } + + function disableGroupRestriction() { + openConfirmDialog({ + title: m.unrestrict_oidc_client({ clientName: client.name }), + message: m.confirm_unrestrict_oidc_client_description({ clientName: client.name }), + confirm: { + label: m.unrestrict(), + destructive: true, + action: async () => { + await oidcService + .updateClient(client.id, { + ...client, + isGroupRestricted: false + }) + .then(() => { + toast.success(m.user_groups_restriction_updated_successfully()); + client.allowedUserGroupIds = []; + client.isGroupRestricted = false; + }) + .catch(axiosErrorToast); + } + } + }); + } + async function createClientSecret() { openConfirmDialog({ title: m.create_new_client_secret(), @@ -120,6 +158,13 @@ {m.oidc_client_name({ name: client.name })} +{#snippet UnrestrictButton()} + +{/snippet} +
- -
+ +
+ +
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index 2689e031..e1e1c504 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -102,7 +102,8 @@ logo: $inputs.logoUrl?.value ? undefined : logo, logoUrl: $inputs.logoUrl?.value, darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo, - darkLogoUrl: $inputs.darkLogoUrl?.value + darkLogoUrl: $inputs.darkLogoUrl?.value, + isGroupRestricted: existingClient?.isGroupRestricted ?? true }); const hasLogo = logo != null || !!$inputs.logoUrl?.value; diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte index bb44ff1a..768285c5 100644 --- a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte @@ -15,11 +15,13 @@ import { backNavigate } from '../../users/navigate-back-util'; import UserGroupForm from '../user-group-form.svelte'; import UserSelection from '../user-selection.svelte'; + import OidcClientSelection from './oidc-client-selection.svelte'; let { data } = $props(); let userGroup = $state({ ...data.userGroup, - userIds: data.userGroup.users.map((u) => u.id) + userIds: data.userGroup.users.map((u) => u.id), + allowedOidcClientIds: data.userGroup.allowedOidcClients.map((c) => c.id) }); const userGroupService = new UserGroupService(); @@ -56,6 +58,17 @@ axiosErrorToast(e); }); } + + async function updateAllowedOidcClients(allowedClients: string[]) { + await userGroupService + .updateAllowedOidcClients(userGroup.id, allowedClients) + .then(() => { + toast.success(m.allowed_oidc_clients_updated_successfully()); + }) + .catch((e) => { + axiosErrorToast(e); + }); + } @@ -110,3 +123,16 @@
+ + + +
+ +
+
diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte new file mode 100644 index 00000000..552bb713 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte @@ -0,0 +1,69 @@ + + +{#snippet LogoCell({ item }: { item: OidcClient })} + {#if item.hasLogo} + + {:else} +
+ {item.name.charAt(0).toUpperCase()} +
+ {/if} +{/snippet} + + !item.isGroupRestricted} + {columns} +/> diff --git a/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte b/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte index 59f359df..812753df 100644 --- a/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte +++ b/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte @@ -10,19 +10,19 @@ AdvancedTableColumn, CreateAdvancedTableActions } from '$lib/types/advanced-table.type'; - import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type'; + import type { UserGroupMinimal } from '$lib/types/user-group.type'; import { axiosErrorToast } from '$lib/utils/error-util'; import { LucidePencil, LucideTrash } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; const userGroupService = new UserGroupService(); - let tableRef: AdvancedTable; + let tableRef: AdvancedTable; export function refresh() { return tableRef?.refresh(); } - const columns: AdvancedTableColumn[] = [ + const columns: AdvancedTableColumn[] = [ { label: 'ID', column: 'id', hidden: true }, { label: m.friendly_name(), column: 'friendlyName', sortable: true }, { label: m.name(), column: 'name', sortable: true }, @@ -38,7 +38,7 @@ { label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell } ]; - const actions: CreateAdvancedTableActions = (group) => [ + const actions: CreateAdvancedTableActions = (group) => [ { label: m.edit(), primary: true, @@ -55,7 +55,7 @@ } ]; - async function deleteUserGroup(userGroup: UserGroup) { + async function deleteUserGroup(userGroup: UserGroupMinimal) { openConfirmDialog({ title: m.delete_name({ name: userGroup.name }), message: m.are_you_sure_you_want_to_delete_this_user_group(), @@ -76,7 +76,7 @@ } -{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })} +{#snippet SourceCell({ item }: { item: UserGroupMinimal })} {item.ldapId ? m.ldap() : m.local()} diff --git a/frontend/src/routes/settings/admin/users/+page.svelte b/frontend/src/routes/settings/admin/users/+page.svelte index d9fa9436..2708bbec 100644 --- a/frontend/src/routes/settings/admin/users/+page.svelte +++ b/frontend/src/routes/settings/admin/users/+page.svelte @@ -64,8 +64,7 @@ (expandAddUser = true)}> {selectedCreateOptions} - - + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 584c6315..1bd67d78 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -7,7 +7,13 @@ const config = { // Consult https://kit.svelte.dev/docs/integrations#preprocessors // for more information about preprocessors preprocess: vitePreprocess(), - + compilerOptions: { + warningFilter: (warning) => { + // Ignore "state_referenced_locally" warnings + if (warning.code === 'state_referenced_locally') return false; + return true; + } + }, kit: { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. diff --git a/tests/data.ts b/tests/data.ts index 01b88b6e..236ffe0c 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -66,7 +66,7 @@ export const oidcClients = { export const userGroups = { developers: { - id: '4110f814-56f1-4b28-8998-752b69bc97c0e', + id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368', friendlyName: 'Developers', name: 'developers' }, diff --git a/tests/resources/images/cloud-logo.png b/tests/resources/images/cloud-logo.png new file mode 100644 index 00000000..cb1a8e0f Binary files /dev/null and b/tests/resources/images/cloud-logo.png differ diff --git a/tests/resources/images/cloud-logo.svg b/tests/resources/images/cloud-logo.svg new file mode 100644 index 00000000..1b6ea276 --- /dev/null +++ b/tests/resources/images/cloud-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/tests/resources/images/nextcloud-logo.png b/tests/resources/images/nextcloud-logo.png deleted file mode 100644 index 5d4e8e06..00000000 Binary files a/tests/resources/images/nextcloud-logo.png and /dev/null differ diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index 1f7e56f2..a0a82692 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -116,30 +116,53 @@ test('Update email configuration', async ({ page }) => { await expect(page.getByLabel('API Key Expiration')).toBeChecked(); }); -test('Update application images', async ({ page }) => { - await page.getByRole('button', { name: 'Expand card' }).nth(4).click(); +test.describe('Update application images', () => { + test.beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'Expand card' }).nth(4).click(); + }); - await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico'); - await page.getByLabel('Light Mode Logo').setInputFiles('resources/images/pingvin-share-logo.png'); - await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/nextcloud-logo.png'); - await page.getByLabel('Default Profile Picture').setInputFiles('resources/images/pingvin-share-logo.png'); - await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg'); - await page.getByRole('button', { name: 'Save' }).last().click(); + test('should upload images', async ({ page }) => { + await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico'); + await page + .getByLabel('Light Mode Logo') + .setInputFiles('resources/images/pingvin-share-logo.png'); + await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/cloud-logo.png'); + await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png'); + await page + .getByLabel('Default Profile Picture') + .setInputFiles('resources/images/pingvin-share-logo.png'); + await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg'); + await page.getByRole('button', { name: 'Save' }).last().click(); - await expect(page.locator('[data-type="success"]')).toHaveText( - 'Images updated successfully. It may take a few minutes to update.' - ); + await expect(page.locator('[data-type="success"]')).toHaveText( + 'Images updated successfully. It may take a few minutes to update.' + ); - await page.request - .get('/api/application-images/favicon') - .then((res) => expect.soft(res.status()).toBe(200)); - await page.request - .get('/api/application-images/logo?light=true') - .then((res) => expect.soft(res.status()).toBe(200)); - await page.request - .get('/api/application-images/logo?light=false') - .then((res) => expect.soft(res.status()).toBe(200)); - await page.request - .get('/api/application-images/background') - .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/favicon') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/logo?light=true') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/logo?light=false') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/email') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/background') + .then((res) => expect.soft(res.status()).toBe(200)); + }); + + test('should only allow png/jpeg for email logo', async ({ page }) => { + const emailLogoInput = page.getByLabel('Email Logo'); + + await emailLogoInput.setInputFiles('assets/cloud-logo.svg'); + await page.getByRole('button', { name: 'Save' }).last().click(); + + await expect(page.locator('[data-type="error"]')).toHaveText( + 'File must be of type .png or .jpg/jpeg' + ); + }); }); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index c847733b..e4c198d2 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -1,5 +1,5 @@ import test, { expect, Page } from '@playwright/test'; -import { oidcClients } from '../data'; +import { oidcClients, userGroups } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; test.beforeEach(async () => await cleanupBackend()); @@ -71,9 +71,9 @@ test('Edit OIDC client', async ({ page }) => { await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); await page.locator('[role="tab"][data-value="light-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-light', 'resources/images/nextcloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png'); await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/nextcloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png'); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await page.getByRole('button', { name: 'Save' }).click(); @@ -117,3 +117,25 @@ test('Delete OIDC client', async ({ page }) => { ); await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible(); }); + +test('Update OIDC client allowed user groups', async ({ page }) => { + await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); + + await page.getByRole('button', { name: 'Restrict' }).click(); + + await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click(); + await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await expect(page.getByText('Allowed user groups updated successfully')).toBeVisible(); + + await page.reload(); + + await expect( + page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox') + ).toHaveAttribute('data-state', 'checked'); + await expect( + page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox') + ).toHaveAttribute('data-state', 'checked'); +}); diff --git a/tests/specs/user-group.spec.ts b/tests/specs/user-group.spec.ts index 7deb39b0..590cce1a 100644 --- a/tests/specs/user-group.spec.ts +++ b/tests/specs/user-group.spec.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test'; -import { userGroups, users } from '../data'; +import { oidcClients, userGroups, users } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; test.beforeEach(async () => await cleanupBackend()); @@ -77,7 +77,7 @@ test('Delete user group', async ({ page }) => { test('Update user group custom claims', async ({ page }) => { await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`); - await page.getByRole('button', { name: 'Expand card' }).click(); + await page.getByRole('button', { name: 'Expand card' }).first().click(); // Add two custom claims await page.getByRole('button', { name: 'Add custom claim' }).click(); @@ -119,3 +119,34 @@ test('Update user group custom claims', async ({ page }) => { 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 }) => { + await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`); + + await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + + // Unrestricted OIDC clients should be checked and disabled + const nextcloudRow = page + .getByRole('row', { name: oidcClients.nextcloud.name }) + .getByRole('checkbox'); + await expect(nextcloudRow).toHaveAttribute('data-state', 'checked'); + await expect(nextcloudRow).toBeDisabled(); + + await page.getByRole('row', { name: oidcClients.tailscale.name }).getByRole('checkbox').click(); + await page.getByRole('row', { name: oidcClients.immich.name }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Save' }).nth(2).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText( + 'Allowed OIDC clients updated successfully' + ); + + await page.reload(); + + await expect( + page.getByRole('row', { name: oidcClients.tailscale.name }).getByRole('checkbox') + ).toHaveAttribute('data-state', 'checked'); + await expect( + page.getByRole('row', { name: oidcClients.immich.name }).getByRole('checkbox') + ).toHaveAttribute('data-state', 'unchecked'); +}); diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 4644f980..8cb48b30 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -1,9 +1,13 @@ import test, { expect, type Page } from '@playwright/test'; -import { signupTokens, users } from '../data'; +import { signupTokens, userGroups, users } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; import passkeyUtil from '../utils/passkey.util'; -async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' | 'Open Signup') { +async function setSignupMode( + page: Page, + mode: 'Disabled' | 'Signup with token' | 'Open Signup', + signout = true +) { await page.goto('/settings/admin/application-configuration'); await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); @@ -15,10 +19,51 @@ async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' 'User creation settings updated successfully.' ); - await page.context().clearCookies(); - await page.goto('/login'); + if (signout) { + await page.context().clearCookies(); + await page.goto('/login'); + } } +test.describe('Signup Token Creation', () => { + test.beforeEach(async ({ page }) => { + await cleanupBackend(); + await setSignupMode(page, 'Signup with token', false); + }); + + test('Create signup token', async ({ page }) => { + await page.goto('/settings/admin/users'); + + await page.getByLabel('Create options').getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Create Signup Token' }).click(); + await page.getByLabel('Expiration').click(); + await page.getByRole('option', { name: 'week' }).click(); + + await page.getByLabel('Usage Limit').fill('8'); + + await page.getByLabel('User Groups').click(); + await page.getByRole('option', { name: userGroups.developers.name }).click(); + await page.getByRole('option', { name: userGroups.designers.name }).click(); + await page.getByLabel('User Groups').click(); + + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.getByLabel('Create options').getByRole('button').click(); + await page.getByRole('menuitem', { name: 'View Active Signup Tokens' }).click(); + await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'View' }).click(); + + await page.getByRole('menuitemcheckbox', { name: 'User Groups' }).click(); + + const row = page.getByRole('row').last(); + await expect(row.getByRole('cell', { name: '0 of 8' })).toBeVisible(); + const dateInAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US'); + await expect(row.getByRole('cell', { name: dateInAWeek })).toBeVisible(); + await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible(); + await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible(); + }); +}); + test.describe('Initial User Signup', () => { test.beforeEach(async ({ page }) => { await page.context().clearCookies(); @@ -74,6 +119,9 @@ test.describe('User Signup', () => { await page.waitForURL('/signup/add-passkey'); await expect(page.getByText('Set up your passkey')).toBeVisible(); + + const response = await page.request.get('/api/users/me').then((res) => res.json()); + expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id); }); test('Signup with token - invalid token shows error', async ({ page }) => {