diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 6a294a61..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 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 b6495de1..bffd430e 100644 --- a/backend/internal/dto/signup_token_dto.go +++ b/backend/internal/dto/signup_token_dto.go @@ -12,11 +12,11 @@ type SignupTokenCreateDto struct { } type SignupTokenDto struct { - ID string `json:"id"` - Token string `json:"token"` - ExpiresAt datatype.DateTime `json:"expiresAt"` - UsageLimit int `json:"usageLimit"` - UsageCount int `json:"usageCount"` - UserGroups []UserGroupDto `json:"userGroups"` - 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 42781a35..671142f5 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -8,18 +8,18 @@ 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 { 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/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/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 cc442251..96a40dbb 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), }, { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 2d42f452..bd0f49fb 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -226,7 +226,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 } @@ -778,6 +778,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 @@ -816,6 +824,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/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/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 f933cb49..5d32a951 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -301,13 +301,17 @@ "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", @@ -471,5 +475,10 @@ "light": "Light", "dark": "Dark", "system": "System", - "signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token." + "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/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/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/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/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/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/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/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/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index 5bcd31a2..5c25c0e2 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()); @@ -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..96f2ef71 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'); +});