Compare commits

..

4 Commits

Author SHA1 Message Date
Elias Schneider
b02dd34c36 add missing postgres migrations 2025-12-23 16:54:41 +01:00
Elias Schneider
c757cfa8c8 adapt tests 2025-12-23 16:47:13 +01:00
Elias Schneider
ca40251776 feat: add ability to edit oidc client group restriction on groups page 2025-12-23 16:03:33 +01:00
Elias Schneider
f61c784988 feat: restrict oidc clients by user groups per default 2025-12-23 13:51:35 +01:00
30 changed files with 460 additions and 102 deletions

View File

@@ -72,7 +72,7 @@ type UserController struct {
// @Description Retrieve all groups a specific user belongs to // @Description Retrieve all groups a specific user belongs to
// @Tags Users,User Groups // @Tags Users,User Groups
// @Param id path string true "User ID" // @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDtoWithUsers // @Success 200 {array} dto.UserGroupDto
// @Router /api/users/{id}/groups [get] // @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) { func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
@@ -82,7 +82,7 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
return return
} }
var groupsDto []dto.UserGroupDtoWithUsers var groupsDto []dto.UserGroupDto
if err := dto.MapStructList(groups, &groupsDto); err != nil { if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -28,6 +28,7 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
userGroupsGroup.PUT("/:id", ugc.update) userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete) userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers) 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 pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by" // @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @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] // @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) { func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
@@ -57,9 +58,9 @@ func (ugc *UserGroupController) list(c *gin.Context) {
} }
// Map the user groups to DTOs // 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 { for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount var groupDto dto.UserGroupMinimalDto
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -72,7 +73,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groupsDto[i] = groupDto groupsDto[i] = groupDto
} }
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{ c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
Data: groupsDto, Data: groupsDto,
Pagination: pagination, Pagination: pagination,
}) })
@@ -85,7 +86,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path string true "User Group ID" // @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers // @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id} [get] // @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) { func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id")) group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -94,7 +95,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -110,7 +111,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information" // @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] // @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) { func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
@@ -125,7 +126,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -142,7 +143,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path string true "User Group ID" // @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information" // @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] // @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) { func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
@@ -157,7 +158,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -192,7 +193,7 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path string true "User Group ID" // @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group" // @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] // @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) { func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto var input dto.UserGroupUpdateUsersDto
@@ -207,7 +208,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -215,3 +216,35 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
c.JSON(http.StatusOK, groupDto) 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)
}

View File

@@ -18,11 +18,12 @@ type OidcClientDto struct {
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"` Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
} }
type OidcClientWithAllowedUserGroupsDto struct { type OidcClientWithAllowedUserGroupsDto struct {
OidcClientDto OidcClientDto
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
} }
type OidcClientWithAllowedGroupsCountDto struct { type OidcClientWithAllowedGroupsCountDto struct {
@@ -43,6 +44,7 @@ type OidcClientUpdateDto struct {
HasDarkLogo bool `json:"hasDarkLogo"` HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"` LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"` DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {

View File

@@ -12,11 +12,11 @@ type SignupTokenCreateDto struct {
} }
type SignupTokenDto struct { type SignupTokenDto struct {
ID string `json:"id"` ID string `json:"id"`
Token string `json:"token"` Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"` ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"` UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"` UsageCount int `json:"usageCount"`
UserGroups []UserGroupDto `json:"userGroups"` UserGroups []UserGroupMinimalDto `json:"userGroups"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }

View File

@@ -8,18 +8,18 @@ import (
) )
type UserDto struct { type UserDto struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email *string `json:"email" ` Email *string `json:"email" `
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName *string `json:"lastName"` LastName *string `json:"lastName"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"` UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"` LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
} }
type UserCreateDto struct { type UserCreateDto struct {

View File

@@ -8,25 +8,17 @@ import (
) )
type UserGroupDto struct { type UserGroupDto struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"` LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
AllowedOidcClients []OidcClientMetaDataDto `json:"allowedOidcClients"`
} }
type UserGroupDtoWithUsers struct { type UserGroupMinimalDto 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 {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
@@ -36,6 +28,10 @@ type UserGroupDtoWithUserCount struct {
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
}
type UserGroupCreateDto struct { type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"` FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"` Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`

View File

@@ -58,6 +58,7 @@ type OidcClient struct {
RequiresReauthentication bool `sortable:"true" filterable:"true"` RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials Credentials OidcClientCredentials
LaunchURL *string LaunchURL *string
IsGroupRestricted bool
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string CreatedByID *string

View File

@@ -2,9 +2,10 @@ package model
type UserGroup struct { type UserGroup struct {
Base Base
FriendlyName string `sortable:"true"` FriendlyName string `sortable:"true"`
Name string `sortable:"true"` Name string `sortable:"true"`
LdapID *string LdapID *string
Users []User `gorm:"many2many:user_groups_users;"` Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
} }

View File

@@ -169,10 +169,11 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{ Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018", ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
}, },
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"}, CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: utils.Ptr(users[1].ID), CreatedByID: utils.Ptr(users[1].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[1], userGroups[1],
}, },
@@ -185,6 +186,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"}, CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"}, LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
IsGroupRestricted: true,
CreatedByID: utils.Ptr(users[0].ID), CreatedByID: utils.Ptr(users[0].ID),
}, },
{ {

View File

@@ -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 // 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 { func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if len(client.AllowedUserGroups) == 0 { if !client.IsGroupRestricted {
return true return true
} }
@@ -778,6 +778,14 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
updateOIDCClientModelFromDto(&client, &input) 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 err = tx.WithContext(ctx).Save(&client).Error
if err != nil { if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
@@ -816,6 +824,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.PkceEnabled = input.IsPublic || input.PkceEnabled client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
// Credentials // Credentials
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities)) client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))

View File

@@ -53,6 +53,7 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
Where("id = ?", id). Where("id = ?", id).
Preload("CustomClaims"). Preload("CustomClaims").
Preload("Users"). Preload("Users").
Preload("AllowedOidcClients").
First(&group). First(&group).
Error Error
return group, err return group, err
@@ -248,3 +249,54 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (
Count() Count()
return count, nil 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
}

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN is_group_restricted;

View File

@@ -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
);

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN is_group_restricted;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -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;

View File

@@ -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.", "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", "generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully", "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}", "oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID", "client_id": "Client ID",
"client_secret": "Client secret", "client_secret": "Client secret",
"show_more_details": "Show more details", "show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups", "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", "favicon": "Favicon",
"light_mode_logo": "Light Mode Logo", "light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo", "dark_mode_logo": "Dark Mode Logo",
@@ -471,5 +475,10 @@
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System", "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 <b>{clientName}</b>? 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"
} }

View File

@@ -12,6 +12,8 @@
title, title,
description, description,
defaultExpanded = false, defaultExpanded = false,
forcedExpanded,
button,
icon, icon,
children children
}: { }: {
@@ -19,7 +21,9 @@
title: string; title: string;
description?: string; description?: string;
defaultExpanded?: boolean; defaultExpanded?: boolean;
forcedExpanded?: boolean;
icon?: typeof IconType; icon?: typeof IconType;
button?: Snippet;
children: Snippet; children: Snippet;
} = $props(); } = $props();
@@ -47,6 +51,12 @@
} }
loadExpandedState(); loadExpandedState();
}); });
$effect(() => {
if (forcedExpanded !== undefined) {
expanded = forcedExpanded;
}
});
</script> </script>
<Card.Root> <Card.Root>
@@ -63,11 +73,18 @@
<Card.Description>{description}</Card.Description> <Card.Description>{description}</Card.Description>
{/if} {/if}
</div> </div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}> {#if button}
<LucideChevronDown {@render button()}
class={cn('size-5 transition-transform duration-200', expanded && 'rotate-180 transform')} {:else}
/> <Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
</Button> <LucideChevronDown
class={cn(
'size-5 transition-transform duration-200',
expanded && 'rotate-180 transform'
)}
/>
</Button>
{/if}
</div> </div>
</Card.Header> </Card.Header>
{#if expanded} {#if expanded}

View File

@@ -25,6 +25,7 @@
selectedIds = $bindable(), selectedIds = $bindable(),
withoutSearch = false, withoutSearch = false,
selectionDisabled = false, selectionDisabled = false,
rowSelectionDisabled,
fetchCallback, fetchCallback,
defaultSort, defaultSort,
columns, columns,
@@ -34,6 +35,7 @@
selectedIds?: string[]; selectedIds?: string[];
withoutSearch?: boolean; withoutSearch?: boolean;
selectionDisabled?: boolean; selectionDisabled?: boolean;
rowSelectionDisabled?: (item: T) => boolean;
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>; fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
defaultSort?: SortRequest; defaultSort?: SortRequest;
columns: AdvancedTableColumn<T>[]; columns: AdvancedTableColumn<T>[];
@@ -91,7 +93,9 @@
}); });
async function onAllCheck(checked: boolean) { 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 ?? []; const current = selectedIds ?? [];
if (checked) { if (checked) {
@@ -264,7 +268,7 @@
{#if selectedIds} {#if selectedIds}
<Table.Cell class="w-12"> <Table.Cell class="w-12">
<Checkbox <Checkbox
disabled={selectionDisabled} disabled={selectionDisabled || rowSelectionDisabled?.(item)}
checked={selectedIds.includes(item.id)} checked={selectedIds.includes(item.id)}
onCheckedChange={(c: boolean) => onCheck(c, item.id)} onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/> />

View File

@@ -3,7 +3,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type'; 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 { let {
selectionDisabled = false, selectionDisabled = false,
@@ -15,7 +15,7 @@
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [ const columns: AdvancedTableColumn<UserGroupMinimal>[] = [
{ label: 'ID', column: 'id', hidden: true }, { label: 'ID', column: 'id', hidden: true },
{ label: m.friendly_name(), column: 'friendlyName', sortable: true }, { label: m.friendly_name(), column: 'friendlyName', sortable: true },
{ label: m.name(), column: 'name', sortable: true }, { label: m.name(), column: 'name', sortable: true },

View File

@@ -1,30 +1,26 @@
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { import type { UserGroup, UserGroupCreate, UserGroupMinimal } from '$lib/types/user-group.type';
UserGroupCreate,
UserGroupWithUserCount,
UserGroupWithUsers
} from '$lib/types/user-group.type';
import APIService from './api-service'; import APIService from './api-service';
export default class UserGroupService extends APIService { export default class UserGroupService extends APIService {
list = async (options?: ListRequestOptions) => { list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/user-groups', { params: options }); const res = await this.api.get('/user-groups', { params: options });
return res.data as Paginated<UserGroupWithUserCount>; return res.data as Paginated<UserGroupMinimal>;
}; };
get = async (id: string) => { get = async (id: string) => {
const res = await this.api.get(`/user-groups/${id}`); const res = await this.api.get(`/user-groups/${id}`);
return res.data as UserGroupWithUsers; return res.data as UserGroup;
}; };
create = async (user: UserGroupCreate) => { create = async (user: UserGroupCreate) => {
const res = await this.api.post('/user-groups', user); 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) => { update = async (id: string, user: UserGroupCreate) => {
const res = await this.api.put(`/user-groups/${id}`, user); const res = await this.api.put(`/user-groups/${id}`, user);
return res.data as UserGroupWithUsers; return res.data as UserGroup;
}; };
remove = async (id: string) => { remove = async (id: string) => {
@@ -33,6 +29,11 @@ export default class UserGroupService extends APIService {
updateUsers = async (id: string, userIds: string[]) => { updateUsers = async (id: string, userIds: string[]) => {
const res = await this.api.put(`/user-groups/${id}/users`, { userIds }); 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;
}; };
} }

View File

@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
requiresReauthentication: boolean; requiresReauthentication: boolean;
credentials?: OidcClientCredentials; credentials?: OidcClientCredentials;
launchURL?: string; launchURL?: string;
isGroupRestricted: boolean;
}; };
export type OidcClientWithAllowedUserGroups = OidcClient & { export type OidcClientWithAllowedUserGroups = OidcClient & {

View File

@@ -1,4 +1,5 @@
import type { CustomClaim } from './custom-claim.type'; import type { CustomClaim } from './custom-claim.type';
import type { OidcClientMetaData } from './oidc.type';
import type { User } from './user.type'; import type { User } from './user.type';
export type UserGroup = { export type UserGroup = {
@@ -8,13 +9,11 @@ export type UserGroup = {
createdAt: string; createdAt: string;
customClaims: CustomClaim[]; customClaims: CustomClaim[];
ldapId?: string; ldapId?: string;
};
export type UserGroupWithUsers = UserGroup & {
users: User[]; users: User[];
allowedOidcClients: OidcClientMetaData[];
}; };
export type UserGroupWithUserCount = UserGroup & { export type UserGroupMinimal = Omit<UserGroup, 'users' | 'allowedOidcClients'> & {
userCount: number; userCount: number;
}; };

View File

@@ -80,6 +80,35 @@
return success; return success;
} }
async function enableGroupRestriction() {
client.isGroupRestricted = true;
await oidcService
.updateClient(client.id, client)
.then(() => toast.success(m.user_groups_restriction_updated_successfully()))
.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 () => {
client.isGroupRestricted = false;
await oidcService
.updateClient(client.id, client)
.then(() => {
toast.success(m.user_groups_restriction_updated_successfully());
client.allowedUserGroupIds = [];
})
.catch(axiosErrorToast);
}
}
});
}
async function createClientSecret() { async function createClientSecret() {
openConfirmDialog({ openConfirmDialog({
title: m.create_new_client_secret(), title: m.create_new_client_secret(),
@@ -120,6 +149,13 @@
<title>{m.oidc_client_name({ name: client.name })}</title> <title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head> </svelte:head>
{#snippet UnrestrictButton()}
<Button
onclick={enableGroupRestriction}
variant={client.isGroupRestricted ? 'secondary' : 'default'}>{m.restrict()}</Button
>
{/snippet}
<div> <div>
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go} <button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
><LucideChevronLeft class="size-5" /> {m.back()}</button ><LucideChevronLeft class="size-5" /> {m.back()}</button
@@ -193,10 +229,19 @@
<CollapsibleCard <CollapsibleCard
id="allowed-user-groups" id="allowed-user-groups"
title={m.allowed_user_groups()} title={m.allowed_user_groups()}
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()} button={!client.isGroupRestricted ? UnrestrictButton : undefined}
forcedExpanded={client.isGroupRestricted ? undefined : false}
description={client.isGroupRestricted
? m.allowed_user_groups_description()
: m.allowed_user_groups_status_unrestricted_description()}
> >
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} /> <UserGroupSelection
<div class="mt-5 flex justify-end"> bind:selectedGroupIds={client.allowedUserGroupIds}
selectionDisabled={!client.isGroupRestricted}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button> <Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -102,7 +102,8 @@
logo: $inputs.logoUrl?.value ? undefined : logo, logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value, logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo, 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; const hasLogo = logo != null || !!$inputs.logoUrl?.value;

View File

@@ -15,11 +15,13 @@
import { backNavigate } from '../../users/navigate-back-util'; import { backNavigate } from '../../users/navigate-back-util';
import UserGroupForm from '../user-group-form.svelte'; import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte'; import UserSelection from '../user-selection.svelte';
import OidcClientSelection from './oidc-client-selection.svelte';
let { data } = $props(); let { data } = $props();
let userGroup = $state({ let userGroup = $state({
...data.userGroup, ...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(); const userGroupService = new UserGroupService();
@@ -56,6 +58,17 @@
axiosErrorToast(e); 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);
});
}
</script> </script>
<svelte:head> <svelte:head>
@@ -110,3 +123,16 @@
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button> <Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard
id="user-group-oidc-clients"
title={m.allowed_oidc_clients()}
description={m.allowed_oidc_clients_description()}
>
<OidcClientSelection bind:selectedGroupIds={userGroup.allowedOidcClientIds} />
<div class="mt-5 flex justify-end gap-3">
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
>{m.save()}</Button
>
</div>
</CollapsibleCard>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import ImageBox from '$lib/components/image-box.svelte';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
import type { OidcClient } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { mode } from 'mode-watcher';
let {
selectedGroupIds = $bindable()
}: {
selectedGroupIds: string[];
} = $props();
const oidcClientService = new OidcService();
const isLightMode = $derived(mode.current === 'light');
const columns: AdvancedTableColumn<OidcClient>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.logo(), key: 'logo', cell: LogoCell },
{ label: m.name(), column: 'name', sortable: true },
{
label: m.client_launch_url(),
column: 'launchURL',
hidden: true
},
{
label: m.public_client(),
column: 'isPublic',
sortable: true,
hidden: true
}
];
async function fetchCallback(requestOptions: ListRequestOptions) {
const clients = await oidcClientService.listClients(requestOptions);
const unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
selectedGroupIds = [...selectedGroupIds, ...unrestrictedClientIds];
return clients;
}
</script>
{#snippet LogoCell({ item }: { item: OidcClient })}
{#if item.hasLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
alt={m.name_logo({ name: item.name })}
/>
{:else}
<div class="bg-muted flex size-12 items-center justify-center rounded-lg text-lg font-bold">
{item.name.charAt(0).toUpperCase()}
</div>
{/if}
{/snippet}
<AdvancedTable
id="oidc-client-selection"
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
bind:selectedIds={selectedGroupIds}
rowSelectionDisabled={(item) => !item.isGroupRestricted}
{columns}
/>

View File

@@ -10,19 +10,19 @@
AdvancedTableColumn, AdvancedTableColumn,
CreateAdvancedTableActions CreateAdvancedTableActions
} from '$lib/types/advanced-table.type'; } 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 { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte'; import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
let tableRef: AdvancedTable<UserGroupWithUserCount>; let tableRef: AdvancedTable<UserGroupMinimal>;
export function refresh() { export function refresh() {
return tableRef?.refresh(); return tableRef?.refresh();
} }
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [ const columns: AdvancedTableColumn<UserGroupMinimal>[] = [
{ label: 'ID', column: 'id', hidden: true }, { label: 'ID', column: 'id', hidden: true },
{ label: m.friendly_name(), column: 'friendlyName', sortable: true }, { label: m.friendly_name(), column: 'friendlyName', sortable: true },
{ label: m.name(), column: 'name', sortable: true }, { label: m.name(), column: 'name', sortable: true },
@@ -38,7 +38,7 @@
{ label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell } { label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell }
]; ];
const actions: CreateAdvancedTableActions<UserGroupWithUserCount> = (group) => [ const actions: CreateAdvancedTableActions<UserGroupMinimal> = (group) => [
{ {
label: m.edit(), label: m.edit(),
primary: true, primary: true,
@@ -55,7 +55,7 @@
} }
]; ];
async function deleteUserGroup(userGroup: UserGroup) { async function deleteUserGroup(userGroup: UserGroupMinimal) {
openConfirmDialog({ openConfirmDialog({
title: m.delete_name({ name: userGroup.name }), title: m.delete_name({ name: userGroup.name }),
message: m.are_you_sure_you_want_to_delete_this_user_group(), message: m.are_you_sure_you_want_to_delete_this_user_group(),
@@ -76,7 +76,7 @@
} }
</script> </script>
{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })} {#snippet SourceCell({ item }: { item: UserGroupMinimal })}
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}> <Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}>
{item.ldapId ? m.ldap() : m.local()} {item.ldapId ? m.ldap() : m.local()}
</Badge> </Badge>

View File

@@ -7,7 +7,13 @@ const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors // Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
compilerOptions: {
warningFilter: (warning) => {
// Ignore "state_referenced_locally" warnings
if (warning.code === 'state_referenced_locally') return false;
return true;
}
},
kit: { kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // 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. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.

View File

@@ -1,5 +1,5 @@
import test, { expect, Page } from '@playwright/test'; import test, { expect, Page } from '@playwright/test';
import { oidcClients } from '../data'; import { oidcClients, userGroups } from '../data';
import { cleanupBackend } from '../utils/cleanup.util'; import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend()); 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(); 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');
});

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { userGroups, users } from '../data'; import { oidcClients, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util'; import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend()); test.beforeEach(async () => await cleanupBackend());
@@ -77,7 +77,7 @@ test('Delete user group', async ({ page }) => {
test('Update user group custom claims', async ({ page }) => { test('Update user group custom claims', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`); await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole('button', { name: 'Expand card' }).click(); await page.getByRole('button', { name: 'Expand card' }).first().click();
// Add two custom claims // Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click(); 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('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value'); 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');
});