From ccd8a60168ce72324274082428a70db1ad501523 Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Thu, 13 Mar 2025 12:00:15 -0400 Subject: [PATCH] Plugin groups (#30320) * add new pluginapi methods * SAML login hook * set ReAddRemovedMembers to true for plugin groups * change to DoLogin signature for SAML --- api/v4/source/groups.yaml | 53 ++ server/channels/api4/group.go | 19 +- server/channels/api4/group_test.go | 347 ++++++++----- server/channels/app/plugin_api.go | 106 ++++ server/channels/app/syncables.go | 21 +- server/channels/store/sqlstore/group_store.go | 12 + .../channels/store/storetest/group_store.go | 30 ++ server/channels/web/saml.go | 15 +- server/einterfaces/mocks/SamlInterface.go | 23 +- server/einterfaces/saml.go | 3 +- server/i18n/en.json | 4 + server/public/go.mod | 6 +- server/public/go.sum | 16 + server/public/model/client4.go | 3 +- server/public/model/group.go | 45 +- server/public/plugin/api.go | 87 ++++ .../plugin/api_timer_layer_generated.go | 98 ++++ server/public/plugin/client_rpc_generated.go | 456 ++++++++++++++++++ server/public/plugin/hooks.go | 7 + .../plugin/hooks_timer_layer_generated.go | 8 + .../public/plugin/interface_generator/main.go | 10 + server/public/plugin/plugintest/api.go | 448 +++++++++++++++++ server/public/plugin/plugintest/hooks.go | 20 + server/public/pluginapi/group.go | 112 +++++ .../add_groups_to_channel_modal.tsx | 10 +- .../add_groups_to_team_modal.tsx | 10 +- .../group_settings.test.tsx.snap | 3 + .../__snapshots__/group_users.test.tsx.snap | 221 +++++++++ .../group_details/group_details.tsx | 1 + .../group_details/group_users.test.tsx | 12 + .../group_details/group_users.tsx | 47 +- .../group_settings/group_settings.tsx | 4 + .../view_user_group_list_item.tsx | 4 +- .../view_user_group_modal.tsx | 11 +- webapp/channels/src/i18n/en.json | 2 + .../mattermost-redux/src/actions/groups.ts | 6 +- .../src/selectors/entities/groups.ts | 6 +- .../src/selectors/entities/roles.ts | 11 +- .../mattermost-redux/src/utils/group_utils.ts | 5 + webapp/channels/src/plugins/registry.ts | 6 + webapp/channels/src/reducers/plugins/index.ts | 1 + webapp/channels/src/types/store/plugins.ts | 5 + webapp/platform/client/src/client4.ts | 7 +- webapp/platform/types/src/groups.ts | 11 + 44 files changed, 2119 insertions(+), 213 deletions(-) diff --git a/api/v4/source/groups.yaml b/api/v4/source/groups.yaml index d679496384..ac0be77b18 100644 --- a/api/v4/source/groups.yaml +++ b/api/v4/source/groups.yaml @@ -1040,6 +1040,59 @@ schema: type: boolean default: false + - name: include_member_count + in: query + description: Boolean which adds a `member_count` field to each group object. + schema: + type: boolean + default: false + - name: include_timezones + in: query + description: Boolean which adds timezone information for group members. + schema: + type: boolean + default: false + - name: include_total_count + in: query + description: Boolean which adds total count of groups in the response. + schema: + type: boolean + default: false + - name: include_archived + in: query + description: Boolean which includes archived groups in the response. + schema: + type: boolean + default: false + - name: filter_archived + in: query + description: Boolean which filters out archived groups from the response. + schema: + type: boolean + default: false + - name: filter_parent_team_permitted + in: query + description: Boolean which filters groups based on parent team permissions. + schema: + type: boolean + default: false + - name: filter_has_member + in: query + description: User ID to filter groups that have this member. + schema: + type: string + - name: include_member_ids + in: query + description: Boolean which adds member IDs to the group objects. + schema: + type: boolean + default: false + - name: only_syncable_sources + in: query + description: Boolean which includes groups from syncable sources. + schema: + type: boolean + default: false responses: "200": description: Group list retrieval successful diff --git a/server/channels/api4/group.go b/server/channels/api4/group.go index 6669f463db..f04b95ff83 100644 --- a/server/channels/api4/group.go +++ b/server/channels/api4/group.go @@ -394,7 +394,7 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventObjectType("group_syncable") c.App.Srv().Go(func() { - c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, false) + c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId) }) w.WriteHeader(http.StatusCreated) @@ -579,7 +579,7 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventObjectType("group_syncable") c.App.Srv().Go(func() { - c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, false) + c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId) }) b, err := json.Marshal(groupSyncable) @@ -641,7 +641,7 @@ func unlinkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } c.App.Srv().Go(func() { - c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, false) + c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId) }) auditRec.Success() @@ -655,7 +655,7 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType return appErr } - if group.Source != model.GroupSourceLdap { + if !group.IsSyncable() { return model.NewAppError("Api4.linkGroupSyncable", "app.group.crud_permission", nil, "", http.StatusBadRequest) } @@ -1023,6 +1023,8 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { source := c.Params.GroupSource + onlySyncableSources := r.URL.Query().Get("only_syncable_sources") == "true" + if id := c.Params.NotAssociatedToTeam; model.IsValidId(id) { teamID = id } @@ -1042,9 +1044,9 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { return } - // If they don't specify a source and custom groups are disabled, ensure they only get ldap groups in the response + // If they don't specify a source and custom groups are disabled, ensure they only get the other sources if !*c.App.Config().ServiceSettings.EnableCustomGroups { - source = model.GroupSourceLdap + onlySyncableSources = true } includeTimezones := r.URL.Query().Get("include_timezones") == "true" @@ -1063,6 +1065,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { IncludeTimezones: includeTimezones, IncludeMemberIDs: c.Params.IncludeMemberIDs, IncludeArchived: includeArchived, + OnlySyncableSources: onlySyncableSources, } if teamID != "" { @@ -1461,6 +1464,10 @@ func licensedAndConfiguredForGroupBySource(app *app.App, source model.GroupSourc return model.NewAppError("", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) } + if strings.HasPrefix(string(source), string(model.GroupSourcePluginPrefix)) && !*lic.Features.LDAPGroups { + return model.NewAppError("", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + } + if source == model.GroupSourceCustom && lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise { return model.NewAppError("", "api.custom_groups.license_error", nil, "", http.StatusBadRequest) } diff --git a/server/channels/api4/group_test.go b/server/channels/api4/group_test.go index 66f541aa94..bfc6869b6d 100644 --- a/server/channels/api4/group_test.go +++ b/server/channels/api4/group_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "strings" "testing" "time" @@ -1543,7 +1544,7 @@ func TestGetGroups(t *testing.T) { }) assert.Nil(t, appErr) - opts := model.GroupSearchOpts{ + baseOpts := model.GroupSearchOpts{ Source: model.GroupSourceLdap, PageOpts: &model.PageOpts{ Page: 0, @@ -1551,142 +1552,222 @@ func TestGetGroups(t *testing.T) { }, } - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) - - _, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) - require.NoError(t, err) - - _, err = th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "") - require.NoError(t, err) - - opts.NotAssociatedToChannel = th.BasicChannel.Id - - _, err = th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "channel_user channel_admin") - require.NoError(t, err) - - groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.ElementsMatch(t, []*model.Group{group, th.Group}, groups) - assert.Nil(t, groups[0].MemberCount) - - opts.IncludeMemberCount = true - groups, _, _ = th.SystemAdminClient.GetGroups(context.Background(), opts) - assert.NotNil(t, groups[0].MemberCount) - opts.IncludeMemberCount = false - - opts.Q = "-fOo" - groups, _, _ = th.SystemAdminClient.GetGroups(context.Background(), opts) - assert.Len(t, groups, 1) - opts.Q = "" - - _, err = th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "") - require.NoError(t, err) - - opts.NotAssociatedToTeam = th.BasicTeam.Id - - _, err = th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "team_user team_admin") - require.NoError(t, err) - - _, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - - // test "since", should only return group created in this test, not th.Group - opts.Since = start - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - // test correct group returned - assert.Equal(t, groups[0].Id, group.Id) - - // delete group, should still return - _, appErr = th.App.DeleteGroup(group.Id) - require.Nil(t, appErr) - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Id, group.Id) - - // test with current since value, return none - opts.Since = model.GetMillis() - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Empty(t, groups) - - // make sure delete group is not returned without Since - opts.Since = 0 - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - //'Normal getGroups should not return delete groups - assert.Len(t, groups, 1) - // make sure it returned th.Group,not group - assert.Equal(t, groups[0].Id, th.Group.Id) - - // Test include_archived parameter - opts.IncludeArchived = true - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 2) - opts.IncludeArchived = false - - // Test returning only archived groups - opts.FilterArchived = true - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Id, group.Id) - opts.FilterArchived = false - - opts.Source = model.GroupSourceCustom - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Id, group2.Id) - - // Test IncludeChannelMemberCount url param is working - opts.IncludeChannelMemberCount = th.BasicChannel.Id - opts.IncludeTimezones = true - opts.Q = "-fOo" - opts.IncludeMemberCount = true - - groups, _, _ = th.SystemAdminClient.GetGroups(context.Background(), opts) - assert.Equal(t, *groups[0].MemberCount, int(0)) - assert.Equal(t, *groups[0].ChannelMemberCount, int(0)) - - _, appErr = th.App.UpsertGroupMember(group2.Id, th.BasicUser.Id) - assert.Nil(t, appErr) - - groups, _, _ = th.SystemAdminClient.GetGroups(context.Background(), opts) - assert.NotNil(t, groups[0].MemberCount) - assert.Equal(t, *groups[0].ChannelMemberCount, int(1)) - - opts.IncludeChannelMemberCount = "" - opts.IncludeTimezones = false - opts.Q = "" - opts.IncludeMemberCount = false - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.EnableCustomGroups = false + t.Run("without license", func(t *testing.T) { + opts := baseOpts + th.App.Srv().SetLicense(nil) + _, response, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.Error(t, err) + CheckNotImplementedStatus(t, response) }) - // Specify custom groups source when feature is disabled - opts.Source = model.GroupSourceCustom - _, response, err := th.Client.GetGroups(context.Background(), opts) - require.Error(t, err) - CheckBadRequestStatus(t, response) + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) - // Specify ldap groups source when custom groups feature is disabled - opts.Source = model.GroupSourceLdap - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Source, model.GroupSourceLdap) + t.Run("basic search", func(t *testing.T) { + opts := baseOpts + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.ElementsMatch(t, []*model.Group{group, th.Group}, groups) + assert.Nil(t, groups[0].MemberCount) + }) - // don't include source and should only get ldap groups in response - opts.Source = "" - groups, _, err = th.Client.GetGroups(context.Background(), opts) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Source, model.GroupSourceLdap) + t.Run("include member count", func(t *testing.T) { + opts := baseOpts + opts.IncludeMemberCount = true + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.NotNil(t, groups[0].MemberCount) + }) + + t.Run("search with Q parameter", func(t *testing.T) { + opts := baseOpts + opts.Q = "-fOo" + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + }) + + t.Run("not associated to channel", func(t *testing.T) { + opts := baseOpts + _, err := th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "") + require.NoError(t, err) + + opts.NotAssociatedToChannel = th.BasicChannel.Id + + _, err = th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "channel_user channel_admin") + require.NoError(t, err) + + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.ElementsMatch(t, []*model.Group{group, th.Group}, groups) + }) + + t.Run("not associated to team", func(t *testing.T) { + opts := baseOpts + _, err := th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "") + require.NoError(t, err) + + opts.NotAssociatedToTeam = th.BasicTeam.Id + + _, err = th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "team_user team_admin") + require.NoError(t, err) + + _, _, err = th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + }) + + t.Run("since parameter", func(t *testing.T) { + opts := baseOpts + opts.Since = start + groups, _, err := th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Id, group.Id) + + opts.Since = model.GetMillis() + groups, _, err = th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Empty(t, groups) + }) + + t.Run("archived groups", func(t *testing.T) { + opts := baseOpts + _, appErr = th.App.DeleteGroup(group.Id) + require.Nil(t, appErr) + + // Test include_archived parameter + opts.IncludeArchived = true + groups, _, err := th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 2) + + // Test returning only archived groups + opts.FilterArchived = true + groups, _, err = th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Id, group.Id) + }) + + t.Run("group source filtering", func(t *testing.T) { + opts := baseOpts + opts.Source = model.GroupSourceCustom + groups, _, err := th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Id, group2.Id) + }) + + t.Run("channel member counts", func(t *testing.T) { + opts := baseOpts + opts.IncludeChannelMemberCount = th.BasicChannel.Id + opts.IncludeTimezones = true + opts.Q = "-fOo" + opts.IncludeMemberCount = true + opts.Source = model.GroupSourceCustom // Switch to custom source to get group2 + + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + require.Len(t, groups, 1) + assert.Equal(t, *groups[0].MemberCount, int(0)) + assert.Equal(t, *groups[0].ChannelMemberCount, int(0)) + + _, appErr = th.App.UpsertGroupMember(group2.Id, th.BasicUser.Id) + require.Nil(t, appErr) + + groups, _, err = th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + require.Len(t, groups, 1) + assert.Equal(t, *groups[0].MemberCount, int(1)) + assert.Equal(t, *groups[0].ChannelMemberCount, int(1)) + }) + + t.Run("custom groups disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableCustomGroups = false + }) + + t.Run("custom source not allowed", func(t *testing.T) { + opts := baseOpts + opts.Source = model.GroupSourceCustom + _, response, err := th.Client.GetGroups(context.Background(), opts) + require.Error(t, err) + CheckBadRequestStatus(t, response) + }) + + t.Run("ldap source allowed", func(t *testing.T) { + opts := baseOpts + opts.Source = model.GroupSourceLdap + groups, _, err := th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Source, model.GroupSourceLdap) + }) + + t.Run("no source specified", func(t *testing.T) { + opts := baseOpts + opts.Source = "" + groups, _, err := th.Client.GetGroups(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Source, model.GroupSourceLdap) + }) + }) + + t.Run("only_syncable_sources parameter", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableCustomGroups = true + }) + + // Create a syncable group with the plugin prefix + id := model.NewId() + _, appErr := th.App.CreateGroup(&model.Group{ + DisplayName: "dn-foo_" + id, + Name: model.NewPointer("name" + id), + Source: model.GroupSourcePluginPrefix + "keycloak", + Description: "description_" + id, + RemoteId: model.NewPointer(model.NewId()), + }) + require.Nil(t, appErr) + + // First test without only_syncable_sources + opts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 60, + }, + } + groups, _, err := th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + // Should return all groups regardless of source when not specified + assert.Len(t, groups, 3) // group, and group2 + + // Test with custom groups disabled and only_syncable_sources=true + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableCustomGroups = false + }) + groups, _, err = th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + // Should still only return LDAP groups + assert.Len(t, groups, 2) + for _, g := range groups { + assert.True(t, g.Source == model.GroupSourceLdap || strings.HasPrefix(string(g.Source), string(model.GroupSourcePluginPrefix))) + } + + // Reset config + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableCustomGroups = true + }) + + // Test with only_syncable_sources=true + opts.OnlySyncableSources = true + groups, _, err = th.SystemAdminClient.GetGroups(context.Background(), opts) + require.NoError(t, err) + // Should only return groups from syncable sources (LDAP and plugin_ groups) + assert.Len(t, groups, 2) + for _, g := range groups { + assert.True(t, g.Source == model.GroupSourceLdap || strings.HasPrefix(string(g.Source), string(model.GroupSourcePluginPrefix))) + } + }) } func TestGetGroupsByUserId(t *testing.T) { diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 0823588340..822579561b 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -39,6 +39,14 @@ func NewPluginAPI(a *App, c request.CTX, manifest *model.Manifest) *PluginAPI { } } +func (api *PluginAPI) checkLDAPLicense() error { + license := api.GetLicense() + if license == nil || !*license.Features.LDAPGroups { + return fmt.Errorf("license does not support LDAP groups") + } + return nil +} + func (api *PluginAPI) LoadPluginConfiguration(dest any) error { finalConfig := make(map[string]any) @@ -656,6 +664,97 @@ func (api *PluginAPI) GetGroupsForUser(userID string) ([]*model.Group, *model.Ap return api.app.GetGroupsByUserId(userID) } +func (api *PluginAPI) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("UpsertGroupMember", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.UpsertGroupMember(groupID, userID) +} + +func (api *PluginAPI) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("UpsertGroupMembers", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.UpsertGroupMembers(groupID, userIDs) +} + +func (api *PluginAPI) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("GetGroupByRemoteID", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.GetGroupByRemoteID(remoteID, groupSource) +} + +func (api *PluginAPI) CreateGroup(group *model.Group) (*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("CreateGroup", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.CreateGroup(group) +} + +func (api *PluginAPI) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("UpdateGroup", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.UpdateGroup(group) +} + +func (api *PluginAPI) DeleteGroup(groupID string) (*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("DeleteGroup", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.DeleteGroup(groupID) +} + +func (api *PluginAPI) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("RestoreGroup", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.RestoreGroup(groupID) +} + +func (api *PluginAPI) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("DeleteGroupMember", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.DeleteGroupMember(groupID, userID) +} + +func (api *PluginAPI) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("GetGroupSyncable", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.GetGroupSyncable(groupID, syncableID, syncableType) +} + +func (api *PluginAPI) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("GetGroupSyncables", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.GetGroupSyncables(groupID, syncableType) +} + +func (api *PluginAPI) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("UpsertGroupSyncable", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.UpsertGroupSyncable(groupSyncable) +} + +func (api *PluginAPI) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("UpdateGroupSyncable", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.UpdateGroupSyncable(groupSyncable) +} + +func (api *PluginAPI) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("DeleteGroupSyncable", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.DeleteGroupSyncable(groupID, syncableID, syncableType) +} + func (api *PluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { post.AddProp("from_plugin", "true") @@ -1347,3 +1446,10 @@ func (api *PluginAPI) UninviteRemoteFromChannel(channelID string, remoteID strin func (api *PluginAPI) GetPluginID() string { return api.id } + +func (api *PluginAPI) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) { + if err := api.checkLDAPLicense(); err != nil { + return nil, model.NewAppError("GetGroups", "app.group.license_error", nil, err.Error(), http.StatusForbidden) + } + return api.app.GetGroups(page, perPage, opts, viewRestrictions) +} diff --git a/server/channels/app/syncables.go b/server/channels/app/syncables.go index d4c3cdeaa5..4b03120043 100644 --- a/server/channels/app/syncables.go +++ b/server/channels/app/syncables.go @@ -270,16 +270,27 @@ func (a *App) SyncSyncableRoles(rctx request.CTX, syncableID string, syncableTyp // SyncRolesAndMembership updates the SchemeAdmin status and membership of all of the members of the given // syncable. -func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, includeRemovedMembers bool) { - appErr := a.SyncSyncableRoles(rctx, syncableID, syncableType) +func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string) { + group, appErr := a.GetGroup(groupID, nil, nil) + if appErr != nil { + rctx.Logger().Warn("Error getting group", mlog.Err(appErr)) + return + } + + appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType) if appErr != nil { rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr)) } - lastJob, _ := a.Srv().Store().Job().GetNewestJobByStatusAndType(model.JobStatusSuccess, model.JobTypeLdapSync) var since int64 - if lastJob != nil { - since = lastJob.StartAt + includeRemovedMembers := true + if group.Source == model.GroupSourceLdap { + lastJob, _ := a.Srv().Store().Job().GetNewestJobByStatusAndType(model.JobStatusSuccess, model.JobTypeLdapSync) + if lastJob != nil { + since = lastJob.StartAt + } + + includeRemovedMembers = false } params := model.CreateDefaultMembershipParams{Since: since, ReAddRemovedMembers: includeRemovedMembers} diff --git a/server/channels/store/sqlstore/group_store.go b/server/channels/store/sqlstore/group_store.go index dbafb68659..02b968cd23 100644 --- a/server/channels/store/sqlstore/group_store.go +++ b/server/channels/store/sqlstore/group_store.go @@ -1620,6 +1620,18 @@ func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts, if opts.Source != "" { groupsQuery = groupsQuery.Where("g.Source = ?", opts.Source) + } else if opts.OnlySyncableSources { + sources := model.GetSyncableGroupSources() + sourcePrefixes := model.GetSyncableGroupSourcePrefixes() + + orClauses := sq.Or{} + if len(sources) > 0 { + orClauses = append(orClauses, sq.Eq{"g.Source": sources}) + } + for _, prefix := range sourcePrefixes { + orClauses = append(orClauses, sq.Like{"g.Source": string(prefix) + "%"}) + } + groupsQuery = groupsQuery.Where(orClauses) } queryString, args, err := groupsQuery.ToSql() diff --git a/server/channels/store/storetest/group_store.go b/server/channels/store/storetest/group_store.go index a7c76be140..2921b329b2 100644 --- a/server/channels/store/storetest/group_store.go +++ b/server/channels/store/storetest/group_store.go @@ -3963,6 +3963,36 @@ func testGetGroups(t *testing.T, rctx request.CTX, ss store.Store) { }, Restrictions: nil, }, + { + Name: "Include syncable sources only", + Opts: model.GroupSearchOpts{OnlySyncableSources: true}, + Page: 0, + PerPage: 100, + Resultf: func(groups []*model.Group) bool { + for _, g := range groups { + if g.Source != model.GroupSourceLdap && !strings.HasPrefix(string(g.Source), "plugin_") { + return false + } + } + return true + }, + Restrictions: nil, + }, + { + Name: "Include syncable sources with specific source", + Opts: model.GroupSearchOpts{OnlySyncableSources: true, Source: model.GroupSourceLdap}, + Page: 0, + PerPage: 100, + Resultf: func(groups []*model.Group) bool { + for _, g := range groups { + if g.Source != model.GroupSourceLdap { + return false + } + } + return true + }, + Restrictions: nil, + }, { Name: "Include archived groups", Opts: model.GroupSearchOpts{IncludeArchived: true, Q: "group-deleted"}, diff --git a/server/channels/web/saml.go b/server/channels/web/saml.go index b0d86f8a48..85af1fb49d 100644 --- a/server/channels/web/saml.go +++ b/server/channels/web/saml.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/channels/utils" ) @@ -135,7 +136,7 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } - user, err := samlInterface.DoLogin(c.AppContext, encodedXML, relayProps) + user, assertion, err := samlInterface.DoLogin(c.AppContext, encodedXML, relayProps) if err != nil { c.LogAudit("fail") handleError(err) @@ -172,6 +173,18 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { }) } + pluginContext := &plugin.Context{ + RequestId: c.AppContext.RequestId(), + SessionId: c.AppContext.Session().Id, + IPAddress: c.AppContext.IPAddress(), + AcceptLanguage: c.AppContext.AcceptLanguage(), + UserAgent: c.AppContext.UserAgent(), + } + c.App.Channels().RunMultiHook(func(hooks plugin.Hooks, manifest *model.Manifest) bool { + err := hooks.OnSAMLLogin(pluginContext, user, assertion) + return err == nil + }, plugin.OnSAMLLoginID) + auditRec.AddMeta("obtained_user_id", user.Id) c.LogAuditWithUserId(user.Id, "obtained user") diff --git a/server/einterfaces/mocks/SamlInterface.go b/server/einterfaces/mocks/SamlInterface.go index 7fee33a61d..248226a51c 100644 --- a/server/einterfaces/mocks/SamlInterface.go +++ b/server/einterfaces/mocks/SamlInterface.go @@ -8,6 +8,8 @@ import ( model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" + + saml2 "github.com/mattermost/gosaml2" ) // SamlInterface is an autogenerated mock type for the SamlInterface type @@ -84,7 +86,7 @@ func (_m *SamlInterface) ConfigureSP(c request.CTX) error { } // DoLogin provides a mock function with given fields: c, encodedXML, relayState -func (_m *SamlInterface) DoLogin(c request.CTX, encodedXML string, relayState map[string]string) (*model.User, *model.AppError) { +func (_m *SamlInterface) DoLogin(c request.CTX, encodedXML string, relayState map[string]string) (*model.User, *saml2.AssertionInfo, *model.AppError) { ret := _m.Called(c, encodedXML, relayState) if len(ret) == 0 { @@ -92,8 +94,9 @@ func (_m *SamlInterface) DoLogin(c request.CTX, encodedXML string, relayState ma } var r0 *model.User - var r1 *model.AppError - if rf, ok := ret.Get(0).(func(request.CTX, string, map[string]string) (*model.User, *model.AppError)); ok { + var r1 *saml2.AssertionInfo + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, map[string]string) (*model.User, *saml2.AssertionInfo, *model.AppError)); ok { return rf(c, encodedXML, relayState) } if rf, ok := ret.Get(0).(func(request.CTX, string, map[string]string) *model.User); ok { @@ -104,15 +107,23 @@ func (_m *SamlInterface) DoLogin(c request.CTX, encodedXML string, relayState ma } } - if rf, ok := ret.Get(1).(func(request.CTX, string, map[string]string) *model.AppError); ok { + if rf, ok := ret.Get(1).(func(request.CTX, string, map[string]string) *saml2.AssertionInfo); ok { r1 = rf(c, encodedXML, relayState) } else { if ret.Get(1) != nil { - r1 = ret.Get(1).(*model.AppError) + r1 = ret.Get(1).(*saml2.AssertionInfo) } } - return r0, r1 + if rf, ok := ret.Get(2).(func(request.CTX, string, map[string]string) *model.AppError); ok { + r2 = rf(c, encodedXML, relayState) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) + } + } + + return r0, r1, r2 } // GetMetadata provides a mock function with given fields: c diff --git a/server/einterfaces/saml.go b/server/einterfaces/saml.go index f6fd6c7897..7b3d5e098c 100644 --- a/server/einterfaces/saml.go +++ b/server/einterfaces/saml.go @@ -4,6 +4,7 @@ package einterfaces import ( + saml2 "github.com/mattermost/gosaml2" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" ) @@ -11,7 +12,7 @@ import ( type SamlInterface interface { ConfigureSP(c request.CTX) error BuildRequest(c request.CTX, relayState string) (*model.SamlAuthRequest, *model.AppError) - DoLogin(c request.CTX, encodedXML string, relayState map[string]string) (*model.User, *model.AppError) + DoLogin(c request.CTX, encodedXML string, relayState map[string]string) (*model.User, *saml2.AssertionInfo, *model.AppError) GetMetadata(c request.CTX) (string, *model.AppError) CheckProviderAttributes(c request.CTX, SS *model.SamlSettings, ouser *model.User, patch *model.UserPatch) string } diff --git a/server/i18n/en.json b/server/i18n/en.json index 8cc3875681..02515927dd 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5314,6 +5314,10 @@ "id": "app.group.id.app_error", "translation": "invalid id property for group." }, + { + "id": "app.group.license_error", + "translation": "LDAP license required." + }, { "id": "app.group.no_rows", "translation": "no matching group found" diff --git a/server/public/go.mod b/server/public/go.mod index 474060c8b5..676c12271d 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -17,6 +17,7 @@ require ( github.com/hashicorp/go-plugin v1.6.3 github.com/lib/pq v1.10.9 github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 + github.com/mattermost/gosaml2 v0.8.0 github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 github.com/mattermost/logr/v2 v2.0.21 github.com/nicksnyder/go-i18n/v2 v2.5.0 @@ -37,6 +38,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/beevik/etree v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect @@ -44,7 +46,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/oklog/run v1.1.0 // indirect @@ -52,6 +56,7 @@ require ( github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/russellhaering/goxmldsig v1.2.0 // indirect github.com/segmentio/backo-go v1.1.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -67,7 +72,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/grpc v1.70.0 // indirect google.golang.org/protobuf v1.36.4 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/server/public/go.sum b/server/public/go.sum index ae28f3895d..4359566acc 100644 --- a/server/public/go.sum +++ b/server/public/go.sum @@ -13,6 +13,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= @@ -93,11 +95,14 @@ github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6 github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -111,10 +116,14 @@ github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= +github.com/mattermost/gosaml2 v0.8.0 h1:nkYiByawqwJ7KncK1LDWKwTx5aRarBTQsmH+XcCVsWQ= +github.com/mattermost/gosaml2 v0.8.0/go.mod h1:1nMAdE2Psxaz+pj79Oytayi+hC3aZUi3SmJQlIe+sLM= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb2BTCsOdamENjjWCI6qmfHLbk6OZI= github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy52be4= github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -152,11 +161,15 @@ github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY= github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30= +github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg= +github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/segmentio/backo-go v1.1.0 h1:cJIfHQUdmLsd8t9IXqf5J8SdrOMn9vMa7cIvOavHAhc= github.com/segmentio/backo-go v1.1.0/go.mod h1:ckenwdf+v/qbyhVdNPWHnqh2YdJBED1O9cidYyM5J18= @@ -339,8 +352,10 @@ google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40Rmc google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= @@ -349,6 +364,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 118a59e524..fb740aedec 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -5862,7 +5862,7 @@ func (c *Client4) GetGroupsAssociatedToChannelsByTeam(ctx context.Context, teamI // GetGroups retrieves Mattermost Groups func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group, *Response, error) { path := fmt.Sprintf( - "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v&include_archived=%v&filter_archived=%v", + "%s?include_member_count=%v¬_associated_to_team=%v¬_associated_to_channel=%v&filter_allow_reference=%v&q=%v&filter_parent_team_permitted=%v&group_source=%v&include_channel_member_count=%v&include_timezones=%v&include_archived=%v&filter_archived=%v&only_syncable_sources=%v", c.groupsRoute(), opts.IncludeMemberCount, opts.NotAssociatedToTeam, @@ -5875,6 +5875,7 @@ func (c *Client4) GetGroups(ctx context.Context, opts GroupSearchOpts) ([]*Group opts.IncludeTimezones, opts.IncludeArchived, opts.FilterArchived, + opts.OnlySyncableSources, ) if opts.Since > 0 { path = fmt.Sprintf("%s&since=%v", path, opts.Since) diff --git a/server/public/model/group.go b/server/public/model/group.go index 95f3576324..3dcd0f658d 100644 --- a/server/public/model/group.go +++ b/server/public/model/group.go @@ -6,12 +6,16 @@ package model import ( "net/http" "regexp" + "strings" ) const ( GroupSourceLdap GroupSource = "ldap" GroupSourceCustom GroupSource = "custom" + // plugin groups must prefix their source with this + GroupSourcePluginPrefix GroupSource = "plugin_" + GroupNameMaxLength = 64 GroupSourceMaxLength = 64 GroupDisplayNameMaxLength = 128 @@ -21,15 +25,6 @@ const ( type GroupSource string -var allGroupSources = []GroupSource{ - GroupSourceLdap, - GroupSourceCustom, -} - -var groupSourcesRequiringRemoteID = []GroupSource{ - GroupSourceLdap, -} - type Group struct { Id string `json:"id"` Name *string `json:"name,omitempty"` @@ -157,6 +152,9 @@ type GroupSearchOpts struct { // Only return archived groups FilterArchived bool + + // OnlySyncableSources filters the groups to only those that are syncable + OnlySyncableSources bool } type GetGroupOpts struct { @@ -214,12 +212,12 @@ func (group *Group) IsValidForCreate() *AppError { } isValidSource := false - for _, groupSource := range allGroupSources { - if group.Source == groupSource { - isValidSource = true - break - } + if group.Source == GroupSourceLdap || + group.Source == GroupSourceCustom || + strings.HasPrefix(string(group.Source), string(GroupSourcePluginPrefix)) { + isValidSource = true } + if !isValidSource { return NewAppError("Group.IsValidForCreate", "model.group.source.app_error", nil, "", http.StatusBadRequest) } @@ -232,12 +230,19 @@ func (group *Group) IsValidForCreate() *AppError { } func (group *Group) requiresRemoteId() bool { - for _, groupSource := range groupSourcesRequiringRemoteID { - if groupSource == group.Source { - return true - } - } - return false + return group.Source == GroupSourceLdap || strings.HasPrefix(string(group.Source), string(GroupSourcePluginPrefix)) +} + +func GetSyncableGroupSources() []GroupSource { + return []GroupSource{GroupSourceLdap} +} + +func GetSyncableGroupSourcePrefixes() []GroupSource { + return []GroupSource{GroupSourcePluginPrefix} +} + +func (group *Group) IsSyncable() bool { + return group.Source == GroupSourceLdap || strings.HasPrefix(string(group.Source), string(GroupSourcePluginPrefix)) } func (group *Group) IsValidForUpdate() *AppError { diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index 41e7f98ed4..1782da9869 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -1300,6 +1300,87 @@ type API interface { // Minimum server version: 9.5 UninviteRemoteFromChannel(channelID string, remoteID string) error + // UpsertGroupMember adds a user to a group or updates their existing membership. + // + // @tag Group + // @tag User + // Minimum server version: 10.7 + UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) + + // UpsertGroupMembers adds multiple users to a group or updates their existing memberships. + // + // @tag Group + // @tag User + // Minimum server version: 10.7 + UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) + + // GetGroupByRemoteID gets a group by its remote ID. + // + // @tag Group + // Minimum server version: 10.7 + GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) + + // CreateGroup creates a new group. + // + // @tag Group + // Minimum server version: 10.7 + CreateGroup(group *model.Group) (*model.Group, *model.AppError) + + // UpdateGroup updates a group. + // + // @tag Group + // Minimum server version: 10.7 + UpdateGroup(group *model.Group) (*model.Group, *model.AppError) + + // DeleteGroup soft deletes a group. + // + // @tag Group + // Minimum server version: 10.7 + DeleteGroup(groupID string) (*model.Group, *model.AppError) + + // RestoreGroup restores a soft deleted group. + // + // @tag Group + // Minimum server version: 10.7 + RestoreGroup(groupID string) (*model.Group, *model.AppError) + + // DeleteGroupMember removes a user from a group. + // + // @tag Group + // @tag User + // Minimum server version: 10.7 + DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) + + // GetGroupSyncable gets a group syncable. + // + // @tag Group + // Minimum server version: 10.7 + GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) + + // GetGroupSyncables gets all group syncables for the given group. + // + // @tag Group + // Minimum server version: 10.7 + GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) + + // UpsertGroupSyncable creates or updates a group syncable. + // + // @tag Group + // Minimum server version: 10.7 + UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) + + // UpdateGroupSyncable updates a group syncable. + // + // @tag Group + // Minimum server version: 10.7 + UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) + + // DeleteGroupSyncable deletes a group syncable. + // + // @tag Group + // Minimum server version: 10.7 + DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) + // UpdateUserRoles updates the role for a user. // // @tag Team @@ -1312,6 +1393,12 @@ type API interface { // @tag Plugin // Minimum server version: 10.1 GetPluginID() string + + // GetGroups returns a list of all groups with the given options and restrictions. + // + // @tag Group + // Minimum server version: 10.7 + GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) } var handshake = plugin.HandshakeConfig{ diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index 8f85a0d6e3..6c52b629bd 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -1372,6 +1372,97 @@ func (api *apiTimerLayer) UninviteRemoteFromChannel(channelID string, remoteID s return _returnsA } +func (api *apiTimerLayer) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UpsertGroupMember(groupID, userID) + api.recordTime(startTime, "UpsertGroupMember", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UpsertGroupMembers(groupID, userIDs) + api.recordTime(startTime, "UpsertGroupMembers", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetGroupByRemoteID(remoteID, groupSource) + api.recordTime(startTime, "GetGroupByRemoteID", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) CreateGroup(group *model.Group) (*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.CreateGroup(group) + api.recordTime(startTime, "CreateGroup", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UpdateGroup(group) + api.recordTime(startTime, "UpdateGroup", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) DeleteGroup(groupID string) (*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.DeleteGroup(groupID) + api.recordTime(startTime, "DeleteGroup", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.RestoreGroup(groupID) + api.recordTime(startTime, "RestoreGroup", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.DeleteGroupMember(groupID, userID) + api.recordTime(startTime, "DeleteGroupMember", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetGroupSyncable(groupID, syncableID, syncableType) + api.recordTime(startTime, "GetGroupSyncable", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetGroupSyncables(groupID, syncableType) + api.recordTime(startTime, "GetGroupSyncables", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UpsertGroupSyncable(groupSyncable) + api.recordTime(startTime, "UpsertGroupSyncable", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.UpdateGroupSyncable(groupSyncable) + api.recordTime(startTime, "UpdateGroupSyncable", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.DeleteGroupSyncable(groupID, syncableID, syncableType) + api.recordTime(startTime, "DeleteGroupSyncable", _returnsB == nil) + return _returnsA, _returnsB +} + func (api *apiTimerLayer) UpdateUserRoles(userID, newRoles string) (*model.User, *model.AppError) { startTime := timePkg.Now() _returnsA, _returnsB := api.apiImpl.UpdateUserRoles(userID, newRoles) @@ -1385,3 +1476,10 @@ func (api *apiTimerLayer) GetPluginID() string { api.recordTime(startTime, "GetPluginID", true) return _returnsA } + +func (api *apiTimerLayer) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetGroups(page, perPage, opts, viewRestrictions) + api.recordTime(startTime, "GetGroups", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 1d31bd791f..8ca15ac11d 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -10,6 +10,7 @@ import ( "fmt" "log" + saml2 "github.com/mattermost/gosaml2" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" ) @@ -1161,6 +1162,43 @@ func (s *hooksRPCServer) GenerateSupportData(args *Z_GenerateSupportDataArgs, re return nil } +func init() { + hookNameToId["OnSAMLLogin"] = OnSAMLLoginID +} + +type Z_OnSAMLLoginArgs struct { + A *Context + B *model.User + C *saml2.AssertionInfo +} + +type Z_OnSAMLLoginReturns struct { + A error +} + +func (g *hooksRPCClient) OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error { + _args := &Z_OnSAMLLoginArgs{c, user, assertion} + _returns := &Z_OnSAMLLoginReturns{} + if g.implemented[OnSAMLLoginID] { + if err := g.client.Call("Plugin.OnSAMLLogin", _args, _returns); err != nil { + g.log.Error("RPC call OnSAMLLogin to plugin failed.", mlog.Err(err)) + } + } + return _returns.A +} + +func (s *hooksRPCServer) OnSAMLLogin(args *Z_OnSAMLLoginArgs, returns *Z_OnSAMLLoginReturns) error { + if hook, ok := s.impl.(interface { + OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error + }); ok { + returns.A = hook.OnSAMLLogin(args.A, args.B, args.C) + returns.A = encodableError(returns.A) + } else { + return encodableError(fmt.Errorf("Hook OnSAMLLogin called but not implemented.")) + } + return nil +} + type Z_RegisterCommandArgs struct { A *model.Command } @@ -6601,6 +6639,392 @@ func (s *apiRPCServer) UninviteRemoteFromChannel(args *Z_UninviteRemoteFromChann return nil } +type Z_UpsertGroupMemberArgs struct { + A string + B string +} + +type Z_UpsertGroupMemberReturns struct { + A *model.GroupMember + B *model.AppError +} + +func (g *apiRPCClient) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + _args := &Z_UpsertGroupMemberArgs{groupID, userID} + _returns := &Z_UpsertGroupMemberReturns{} + if err := g.client.Call("Plugin.UpsertGroupMember", _args, _returns); err != nil { + log.Printf("RPC call to UpsertGroupMember API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UpsertGroupMember(args *Z_UpsertGroupMemberArgs, returns *Z_UpsertGroupMemberReturns) error { + if hook, ok := s.impl.(interface { + UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) + }); ok { + returns.A, returns.B = hook.UpsertGroupMember(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API UpsertGroupMember called but not implemented.")) + } + return nil +} + +type Z_UpsertGroupMembersArgs struct { + A string + B []string +} + +type Z_UpsertGroupMembersReturns struct { + A []*model.GroupMember + B *model.AppError +} + +func (g *apiRPCClient) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) { + _args := &Z_UpsertGroupMembersArgs{groupID, userIDs} + _returns := &Z_UpsertGroupMembersReturns{} + if err := g.client.Call("Plugin.UpsertGroupMembers", _args, _returns); err != nil { + log.Printf("RPC call to UpsertGroupMembers API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UpsertGroupMembers(args *Z_UpsertGroupMembersArgs, returns *Z_UpsertGroupMembersReturns) error { + if hook, ok := s.impl.(interface { + UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) + }); ok { + returns.A, returns.B = hook.UpsertGroupMembers(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API UpsertGroupMembers called but not implemented.")) + } + return nil +} + +type Z_GetGroupByRemoteIDArgs struct { + A string + B model.GroupSource +} + +type Z_GetGroupByRemoteIDReturns struct { + A *model.Group + B *model.AppError +} + +func (g *apiRPCClient) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) { + _args := &Z_GetGroupByRemoteIDArgs{remoteID, groupSource} + _returns := &Z_GetGroupByRemoteIDReturns{} + if err := g.client.Call("Plugin.GetGroupByRemoteID", _args, _returns); err != nil { + log.Printf("RPC call to GetGroupByRemoteID API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetGroupByRemoteID(args *Z_GetGroupByRemoteIDArgs, returns *Z_GetGroupByRemoteIDReturns) error { + if hook, ok := s.impl.(interface { + GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetGroupByRemoteID(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API GetGroupByRemoteID called but not implemented.")) + } + return nil +} + +type Z_CreateGroupArgs struct { + A *model.Group +} + +type Z_CreateGroupReturns struct { + A *model.Group + B *model.AppError +} + +func (g *apiRPCClient) CreateGroup(group *model.Group) (*model.Group, *model.AppError) { + _args := &Z_CreateGroupArgs{group} + _returns := &Z_CreateGroupReturns{} + if err := g.client.Call("Plugin.CreateGroup", _args, _returns); err != nil { + log.Printf("RPC call to CreateGroup API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) CreateGroup(args *Z_CreateGroupArgs, returns *Z_CreateGroupReturns) error { + if hook, ok := s.impl.(interface { + CreateGroup(group *model.Group) (*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.CreateGroup(args.A) + } else { + return encodableError(fmt.Errorf("API CreateGroup called but not implemented.")) + } + return nil +} + +type Z_UpdateGroupArgs struct { + A *model.Group +} + +type Z_UpdateGroupReturns struct { + A *model.Group + B *model.AppError +} + +func (g *apiRPCClient) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) { + _args := &Z_UpdateGroupArgs{group} + _returns := &Z_UpdateGroupReturns{} + if err := g.client.Call("Plugin.UpdateGroup", _args, _returns); err != nil { + log.Printf("RPC call to UpdateGroup API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UpdateGroup(args *Z_UpdateGroupArgs, returns *Z_UpdateGroupReturns) error { + if hook, ok := s.impl.(interface { + UpdateGroup(group *model.Group) (*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.UpdateGroup(args.A) + } else { + return encodableError(fmt.Errorf("API UpdateGroup called but not implemented.")) + } + return nil +} + +type Z_DeleteGroupArgs struct { + A string +} + +type Z_DeleteGroupReturns struct { + A *model.Group + B *model.AppError +} + +func (g *apiRPCClient) DeleteGroup(groupID string) (*model.Group, *model.AppError) { + _args := &Z_DeleteGroupArgs{groupID} + _returns := &Z_DeleteGroupReturns{} + if err := g.client.Call("Plugin.DeleteGroup", _args, _returns); err != nil { + log.Printf("RPC call to DeleteGroup API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) DeleteGroup(args *Z_DeleteGroupArgs, returns *Z_DeleteGroupReturns) error { + if hook, ok := s.impl.(interface { + DeleteGroup(groupID string) (*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.DeleteGroup(args.A) + } else { + return encodableError(fmt.Errorf("API DeleteGroup called but not implemented.")) + } + return nil +} + +type Z_RestoreGroupArgs struct { + A string +} + +type Z_RestoreGroupReturns struct { + A *model.Group + B *model.AppError +} + +func (g *apiRPCClient) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + _args := &Z_RestoreGroupArgs{groupID} + _returns := &Z_RestoreGroupReturns{} + if err := g.client.Call("Plugin.RestoreGroup", _args, _returns); err != nil { + log.Printf("RPC call to RestoreGroup API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) RestoreGroup(args *Z_RestoreGroupArgs, returns *Z_RestoreGroupReturns) error { + if hook, ok := s.impl.(interface { + RestoreGroup(groupID string) (*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.RestoreGroup(args.A) + } else { + return encodableError(fmt.Errorf("API RestoreGroup called but not implemented.")) + } + return nil +} + +type Z_DeleteGroupMemberArgs struct { + A string + B string +} + +type Z_DeleteGroupMemberReturns struct { + A *model.GroupMember + B *model.AppError +} + +func (g *apiRPCClient) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + _args := &Z_DeleteGroupMemberArgs{groupID, userID} + _returns := &Z_DeleteGroupMemberReturns{} + if err := g.client.Call("Plugin.DeleteGroupMember", _args, _returns); err != nil { + log.Printf("RPC call to DeleteGroupMember API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) DeleteGroupMember(args *Z_DeleteGroupMemberArgs, returns *Z_DeleteGroupMemberReturns) error { + if hook, ok := s.impl.(interface { + DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) + }); ok { + returns.A, returns.B = hook.DeleteGroupMember(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API DeleteGroupMember called but not implemented.")) + } + return nil +} + +type Z_GetGroupSyncableArgs struct { + A string + B string + C model.GroupSyncableType +} + +type Z_GetGroupSyncableReturns struct { + A *model.GroupSyncable + B *model.AppError +} + +func (g *apiRPCClient) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + _args := &Z_GetGroupSyncableArgs{groupID, syncableID, syncableType} + _returns := &Z_GetGroupSyncableReturns{} + if err := g.client.Call("Plugin.GetGroupSyncable", _args, _returns); err != nil { + log.Printf("RPC call to GetGroupSyncable API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetGroupSyncable(args *Z_GetGroupSyncableArgs, returns *Z_GetGroupSyncableReturns) error { + if hook, ok := s.impl.(interface { + GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetGroupSyncable(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API GetGroupSyncable called but not implemented.")) + } + return nil +} + +type Z_GetGroupSyncablesArgs struct { + A string + B model.GroupSyncableType +} + +type Z_GetGroupSyncablesReturns struct { + A []*model.GroupSyncable + B *model.AppError +} + +func (g *apiRPCClient) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) { + _args := &Z_GetGroupSyncablesArgs{groupID, syncableType} + _returns := &Z_GetGroupSyncablesReturns{} + if err := g.client.Call("Plugin.GetGroupSyncables", _args, _returns); err != nil { + log.Printf("RPC call to GetGroupSyncables API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetGroupSyncables(args *Z_GetGroupSyncablesArgs, returns *Z_GetGroupSyncablesReturns) error { + if hook, ok := s.impl.(interface { + GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetGroupSyncables(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API GetGroupSyncables called but not implemented.")) + } + return nil +} + +type Z_UpsertGroupSyncableArgs struct { + A *model.GroupSyncable +} + +type Z_UpsertGroupSyncableReturns struct { + A *model.GroupSyncable + B *model.AppError +} + +func (g *apiRPCClient) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + _args := &Z_UpsertGroupSyncableArgs{groupSyncable} + _returns := &Z_UpsertGroupSyncableReturns{} + if err := g.client.Call("Plugin.UpsertGroupSyncable", _args, _returns); err != nil { + log.Printf("RPC call to UpsertGroupSyncable API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UpsertGroupSyncable(args *Z_UpsertGroupSyncableArgs, returns *Z_UpsertGroupSyncableReturns) error { + if hook, ok := s.impl.(interface { + UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) + }); ok { + returns.A, returns.B = hook.UpsertGroupSyncable(args.A) + } else { + return encodableError(fmt.Errorf("API UpsertGroupSyncable called but not implemented.")) + } + return nil +} + +type Z_UpdateGroupSyncableArgs struct { + A *model.GroupSyncable +} + +type Z_UpdateGroupSyncableReturns struct { + A *model.GroupSyncable + B *model.AppError +} + +func (g *apiRPCClient) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + _args := &Z_UpdateGroupSyncableArgs{groupSyncable} + _returns := &Z_UpdateGroupSyncableReturns{} + if err := g.client.Call("Plugin.UpdateGroupSyncable", _args, _returns); err != nil { + log.Printf("RPC call to UpdateGroupSyncable API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) UpdateGroupSyncable(args *Z_UpdateGroupSyncableArgs, returns *Z_UpdateGroupSyncableReturns) error { + if hook, ok := s.impl.(interface { + UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) + }); ok { + returns.A, returns.B = hook.UpdateGroupSyncable(args.A) + } else { + return encodableError(fmt.Errorf("API UpdateGroupSyncable called but not implemented.")) + } + return nil +} + +type Z_DeleteGroupSyncableArgs struct { + A string + B string + C model.GroupSyncableType +} + +type Z_DeleteGroupSyncableReturns struct { + A *model.GroupSyncable + B *model.AppError +} + +func (g *apiRPCClient) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + _args := &Z_DeleteGroupSyncableArgs{groupID, syncableID, syncableType} + _returns := &Z_DeleteGroupSyncableReturns{} + if err := g.client.Call("Plugin.DeleteGroupSyncable", _args, _returns); err != nil { + log.Printf("RPC call to DeleteGroupSyncable API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) DeleteGroupSyncable(args *Z_DeleteGroupSyncableArgs, returns *Z_DeleteGroupSyncableReturns) error { + if hook, ok := s.impl.(interface { + DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) + }); ok { + returns.A, returns.B = hook.DeleteGroupSyncable(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API DeleteGroupSyncable called but not implemented.")) + } + return nil +} + type Z_UpdateUserRolesArgs struct { A string B string @@ -6657,3 +7081,35 @@ func (s *apiRPCServer) GetPluginID(args *Z_GetPluginIDArgs, returns *Z_GetPlugin } return nil } + +type Z_GetGroupsArgs struct { + A int + B int + C model.GroupSearchOpts + D *model.ViewUsersRestrictions +} + +type Z_GetGroupsReturns struct { + A []*model.Group + B *model.AppError +} + +func (g *apiRPCClient) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) { + _args := &Z_GetGroupsArgs{page, perPage, opts, viewRestrictions} + _returns := &Z_GetGroupsReturns{} + if err := g.client.Call("Plugin.GetGroups", _args, _returns); err != nil { + log.Printf("RPC call to GetGroups API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetGroups(args *Z_GetGroupsArgs, returns *Z_GetGroupsReturns) error { + if hook, ok := s.impl.(interface { + GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetGroups(args.A, args.B, args.C, args.D) + } else { + return encodableError(fmt.Errorf("API GetGroups called but not implemented.")) + } + return nil +} diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index c44b36d956..702cce0f27 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + saml2 "github.com/mattermost/gosaml2" "github.com/mattermost/mattermost/server/public/model" ) @@ -61,6 +62,7 @@ const ( OnSharedChannelsAttachmentSyncMsgID = 43 OnSharedChannelsProfileImageSyncMsgID = 44 GenerateSupportDataID = 45 + OnSAMLLoginID = 46 TotalHooksID = iota ) @@ -395,4 +397,9 @@ type Hooks interface { // // Minimum server version: 9.8 GenerateSupportData(c *Context) ([]*model.FileData, error) + + // OnSAMLLogin is invoked after a successful SAML login. + // + // Minimum server version: 10.7 + OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error } diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index 130a8c0545..cdf3e62c98 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -11,6 +11,7 @@ import ( "net/http" timePkg "time" + saml2 "github.com/mattermost/gosaml2" "github.com/mattermost/mattermost/server/public/model" ) @@ -291,3 +292,10 @@ func (hooks *hooksTimerLayer) GenerateSupportData(c *Context) ([]*model.FileData hooks.recordTime(startTime, "GenerateSupportData", _returnsB == nil) return _returnsA, _returnsB } + +func (hooks *hooksTimerLayer) OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error { + startTime := timePkg.Now() + _returnsA := hooks.hooksImpl.OnSAMLLogin(c, user, assertion) + hooks.recordTime(startTime, "OnSAMLLogin", _returnsA == nil) + return _returnsA +} diff --git a/server/public/plugin/interface_generator/main.go b/server/public/plugin/interface_generator/main.go index 2a05d7879b..8367f30739 100644 --- a/server/public/plugin/interface_generator/main.go +++ b/server/public/plugin/interface_generator/main.go @@ -307,6 +307,15 @@ var hooksTemplate = `// Copyright (c) 2015-present Mattermost, Inc. All Rights R package plugin +import ( + "fmt" + "log" + + saml2 "github.com/mattermost/gosaml2" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + {{range .HooksMethods}} func init() { @@ -432,6 +441,7 @@ import ( "net/http" timePkg "time" + saml2 "github.com/mattermost/gosaml2" "github.com/mattermost/mattermost/server/public/model" ) diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 7417f16dd3..c4b7029edd 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -272,6 +272,38 @@ func (_m *API) CreateCommand(cmd *model.Command) (*model.Command, error) { return r0, r1 } +// CreateGroup provides a mock function with given fields: group +func (_m *API) CreateGroup(group *model.Group) (*model.Group, *model.AppError) { + ret := _m.Called(group) + + if len(ret) == 0 { + panic("no return value specified for CreateGroup") + } + + var r0 *model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(*model.Group) (*model.Group, *model.AppError)); ok { + return rf(group) + } + if rf, ok := ret.Get(0).(func(*model.Group) *model.Group); ok { + r0 = rf(group) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(*model.Group) *model.AppError); ok { + r1 = rf(group) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // CreateOAuthApp provides a mock function with given fields: app func (_m *API) CreateOAuthApp(app *model.OAuthApp) (*model.OAuthApp, *model.AppError) { ret := _m.Called(app) @@ -653,6 +685,102 @@ func (_m *API) DeleteEphemeralPost(userID string, postId string) { _m.Called(userID, postId) } +// DeleteGroup provides a mock function with given fields: groupID +func (_m *API) DeleteGroup(groupID string) (*model.Group, *model.AppError) { + ret := _m.Called(groupID) + + if len(ret) == 0 { + panic("no return value specified for DeleteGroup") + } + + var r0 *model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string) (*model.Group, *model.AppError)); ok { + return rf(groupID) + } + if rf, ok := ret.Get(0).(func(string) *model.Group); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(string) *model.AppError); ok { + r1 = rf(groupID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// DeleteGroupMember provides a mock function with given fields: groupID, userID +func (_m *API) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + ret := _m.Called(groupID, userID) + + if len(ret) == 0 { + panic("no return value specified for DeleteGroupMember") + } + + var r0 *model.GroupMember + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string) (*model.GroupMember, *model.AppError)); ok { + return rf(groupID, userID) + } + if rf, ok := ret.Get(0).(func(string, string) *model.GroupMember); ok { + r0 = rf(groupID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupMember) + } + } + + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(groupID, userID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// DeleteGroupSyncable provides a mock function with given fields: groupID, syncableID, syncableType +func (_m *API) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + ret := _m.Called(groupID, syncableID, syncableType) + + if len(ret) == 0 { + panic("no return value specified for DeleteGroupSyncable") + } + + var r0 *model.GroupSyncable + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) (*model.GroupSyncable, *model.AppError)); ok { + return rf(groupID, syncableID, syncableType) + } + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) *model.GroupSyncable); ok { + r0 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupSyncable) + } + } + + if rf, ok := ret.Get(1).(func(string, string, model.GroupSyncableType) *model.AppError); ok { + r1 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // DeleteOAuthApp provides a mock function with given fields: appID func (_m *API) DeleteOAuthApp(appID string) *model.AppError { ret := _m.Called(appID) @@ -1758,6 +1886,38 @@ func (_m *API) GetGroupByName(name string) (*model.Group, *model.AppError) { return r0, r1 } +// GetGroupByRemoteID provides a mock function with given fields: remoteID, groupSource +func (_m *API) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) { + ret := _m.Called(remoteID, groupSource) + + if len(ret) == 0 { + panic("no return value specified for GetGroupByRemoteID") + } + + var r0 *model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, model.GroupSource) (*model.Group, *model.AppError)); ok { + return rf(remoteID, groupSource) + } + if rf, ok := ret.Get(0).(func(string, model.GroupSource) *model.Group); ok { + r0 = rf(remoteID, groupSource) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(string, model.GroupSource) *model.AppError); ok { + r1 = rf(remoteID, groupSource) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetGroupChannel provides a mock function with given fields: userIds func (_m *API) GetGroupChannel(userIds []string) (*model.Channel, *model.AppError) { ret := _m.Called(userIds) @@ -1822,6 +1982,102 @@ func (_m *API) GetGroupMemberUsers(groupID string, page int, perPage int) ([]*mo return r0, r1 } +// GetGroupSyncable provides a mock function with given fields: groupID, syncableID, syncableType +func (_m *API) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + ret := _m.Called(groupID, syncableID, syncableType) + + if len(ret) == 0 { + panic("no return value specified for GetGroupSyncable") + } + + var r0 *model.GroupSyncable + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) (*model.GroupSyncable, *model.AppError)); ok { + return rf(groupID, syncableID, syncableType) + } + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) *model.GroupSyncable); ok { + r0 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupSyncable) + } + } + + if rf, ok := ret.Get(1).(func(string, string, model.GroupSyncableType) *model.AppError); ok { + r1 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetGroupSyncables provides a mock function with given fields: groupID, syncableType +func (_m *API) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) { + ret := _m.Called(groupID, syncableType) + + if len(ret) == 0 { + panic("no return value specified for GetGroupSyncables") + } + + var r0 []*model.GroupSyncable + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError)); ok { + return rf(groupID, syncableType) + } + if rf, ok := ret.Get(0).(func(string, model.GroupSyncableType) []*model.GroupSyncable); ok { + r0 = rf(groupID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.GroupSyncable) + } + } + + if rf, ok := ret.Get(1).(func(string, model.GroupSyncableType) *model.AppError); ok { + r1 = rf(groupID, syncableType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetGroups provides a mock function with given fields: page, perPage, opts, viewRestrictions +func (_m *API) GetGroups(page int, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) { + ret := _m.Called(page, perPage, opts, viewRestrictions) + + if len(ret) == 0 { + panic("no return value specified for GetGroups") + } + + var r0 []*model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(int, int, model.GroupSearchOpts, *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError)); ok { + return rf(page, perPage, opts, viewRestrictions) + } + if rf, ok := ret.Get(0).(func(int, int, model.GroupSearchOpts, *model.ViewUsersRestrictions) []*model.Group); ok { + r0 = rf(page, perPage, opts, viewRestrictions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(int, int, model.GroupSearchOpts, *model.ViewUsersRestrictions) *model.AppError); ok { + r1 = rf(page, perPage, opts, viewRestrictions) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetGroupsBySource provides a mock function with given fields: groupSource func (_m *API) GetGroupsBySource(groupSource model.GroupSource) ([]*model.Group, *model.AppError) { ret := _m.Called(groupSource) @@ -4087,6 +4343,38 @@ func (_m *API) RequestTrialLicense(requesterID string, users int, termsAccepted return r0 } +// RestoreGroup provides a mock function with given fields: groupID +func (_m *API) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + ret := _m.Called(groupID) + + if len(ret) == 0 { + panic("no return value specified for RestoreGroup") + } + + var r0 *model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string) (*model.Group, *model.AppError)); ok { + return rf(groupID) + } + if rf, ok := ret.Get(0).(func(string) *model.Group); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(string) *model.AppError); ok { + r1 = rf(groupID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // RevokeSession provides a mock function with given fields: sessionID func (_m *API) RevokeSession(sessionID string) *model.AppError { ret := _m.Called(sessionID) @@ -4837,6 +5125,70 @@ func (_m *API) UpdateEphemeralPost(userID string, post *model.Post) *model.Post return r0 } +// UpdateGroup provides a mock function with given fields: group +func (_m *API) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) { + ret := _m.Called(group) + + if len(ret) == 0 { + panic("no return value specified for UpdateGroup") + } + + var r0 *model.Group + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(*model.Group) (*model.Group, *model.AppError)); ok { + return rf(group) + } + if rf, ok := ret.Get(0).(func(*model.Group) *model.Group); ok { + r0 = rf(group) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + if rf, ok := ret.Get(1).(func(*model.Group) *model.AppError); ok { + r1 = rf(group) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpdateGroupSyncable provides a mock function with given fields: groupSyncable +func (_m *API) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + ret := _m.Called(groupSyncable) + + if len(ret) == 0 { + panic("no return value specified for UpdateGroupSyncable") + } + + var r0 *model.GroupSyncable + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) (*model.GroupSyncable, *model.AppError)); ok { + return rf(groupSyncable) + } + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) *model.GroupSyncable); ok { + r0 = rf(groupSyncable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupSyncable) + } + } + + if rf, ok := ret.Get(1).(func(*model.GroupSyncable) *model.AppError); ok { + r1 = rf(groupSyncable) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // UpdateOAuthApp provides a mock function with given fields: app func (_m *API) UpdateOAuthApp(app *model.OAuthApp) (*model.OAuthApp, *model.AppError) { ret := _m.Called(app) @@ -5263,6 +5615,102 @@ func (_m *API) UploadFile(data []byte, channelId string, filename string) (*mode return r0, r1 } +// UpsertGroupMember provides a mock function with given fields: groupID, userID +func (_m *API) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + ret := _m.Called(groupID, userID) + + if len(ret) == 0 { + panic("no return value specified for UpsertGroupMember") + } + + var r0 *model.GroupMember + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string) (*model.GroupMember, *model.AppError)); ok { + return rf(groupID, userID) + } + if rf, ok := ret.Get(0).(func(string, string) *model.GroupMember); ok { + r0 = rf(groupID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupMember) + } + } + + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(groupID, userID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpsertGroupMembers provides a mock function with given fields: groupID, userIDs +func (_m *API) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) { + ret := _m.Called(groupID, userIDs) + + if len(ret) == 0 { + panic("no return value specified for UpsertGroupMembers") + } + + var r0 []*model.GroupMember + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, []string) ([]*model.GroupMember, *model.AppError)); ok { + return rf(groupID, userIDs) + } + if rf, ok := ret.Get(0).(func(string, []string) []*model.GroupMember); ok { + r0 = rf(groupID, userIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.GroupMember) + } + } + + if rf, ok := ret.Get(1).(func(string, []string) *model.AppError); ok { + r1 = rf(groupID, userIDs) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpsertGroupSyncable provides a mock function with given fields: groupSyncable +func (_m *API) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + ret := _m.Called(groupSyncable) + + if len(ret) == 0 { + panic("no return value specified for UpsertGroupSyncable") + } + + var r0 *model.GroupSyncable + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) (*model.GroupSyncable, *model.AppError)); ok { + return rf(groupSyncable) + } + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) *model.GroupSyncable); ok { + r0 = rf(groupSyncable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.GroupSyncable) + } + } + + if rf, ok := ret.Get(1).(func(*model.GroupSyncable) *model.AppError); ok { + r1 = rf(groupSyncable) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // NewAPI creates a new instance of API. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewAPI(t interface { diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index 69f8477c71..00d693da94 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -13,6 +13,8 @@ import ( model "github.com/mattermost/mattermost/server/public/model" plugin "github.com/mattermost/mattermost/server/public/plugin" + + saml2 "github.com/mattermost/gosaml2" ) // Hooks is an autogenerated mock type for the Hooks type @@ -384,6 +386,24 @@ func (_m *Hooks) OnPluginClusterEvent(c *plugin.Context, ev model.PluginClusterE _m.Called(c, ev) } +// OnSAMLLogin provides a mock function with given fields: c, user, assertion +func (_m *Hooks) OnSAMLLogin(c *plugin.Context, user *model.User, assertion *saml2.AssertionInfo) error { + ret := _m.Called(c, user, assertion) + + if len(ret) == 0 { + panic("no return value specified for OnSAMLLogin") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.User, *saml2.AssertionInfo) error); ok { + r0 = rf(c, user, assertion) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // OnSendDailyTelemetry provides a mock function with given fields: func (_m *Hooks) OnSendDailyTelemetry() { _m.Called() diff --git a/server/public/pluginapi/group.go b/server/public/pluginapi/group.go index 0aa9f01ca3..8acd89ad8d 100644 --- a/server/public/pluginapi/group.go +++ b/server/public/pluginapi/group.go @@ -55,3 +55,115 @@ func (g *GroupService) ListForUser(userID string) ([]*model.Group, error) { return groups, normalizeAppErr(appErr) } + +// UpsertMember adds a user to a group or updates their existing membership. +// +// Minimum server version: 10.7 +func (g *GroupService) UpsertMember(groupID string, userID string) (*model.GroupMember, error) { + member, appErr := g.api.UpsertGroupMember(groupID, userID) + return member, normalizeAppErr(appErr) +} + +// UpsertMembers adds multiple users to a group or updates their existing memberships. +// +// Minimum server version: 10.7 +func (g *GroupService) UpsertMembers(groupID string, userIDs []string) ([]*model.GroupMember, error) { + members, appErr := g.api.UpsertGroupMembers(groupID, userIDs) + return members, normalizeAppErr(appErr) +} + +// GetByRemoteID gets a group by its remote ID. +// +// Minimum server version: 10.7 +func (g *GroupService) GetByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, error) { + group, appErr := g.api.GetGroupByRemoteID(remoteID, groupSource) + return group, normalizeAppErr(appErr) +} + +// Create creates a new group. +// +// Minimum server version: 10.7 +func (g *GroupService) Create(group *model.Group) (*model.Group, error) { + group, appErr := g.api.CreateGroup(group) + return group, normalizeAppErr(appErr) +} + +// Update updates a group. +// +// Minimum server version: 10.7 +func (g *GroupService) Update(group *model.Group) (*model.Group, error) { + group, appErr := g.api.UpdateGroup(group) + return group, normalizeAppErr(appErr) +} + +// Delete soft deletes a group. +// +// Minimum server version: 10.7 +func (g *GroupService) Delete(groupID string) (*model.Group, error) { + group, appErr := g.api.DeleteGroup(groupID) + return group, normalizeAppErr(appErr) +} + +// Restore restores a soft deleted group. +// +// Minimum server version: 10.7 +func (g *GroupService) Restore(groupID string) (*model.Group, error) { + group, appErr := g.api.RestoreGroup(groupID) + return group, normalizeAppErr(appErr) +} + +// DeleteMember removes a user from a group. +// +// Minimum server version: 10.7 +func (g *GroupService) DeleteMember(groupID string, userID string) (*model.GroupMember, error) { + member, appErr := g.api.DeleteGroupMember(groupID, userID) + return member, normalizeAppErr(appErr) +} + +// GetSyncable gets a group syncable. +// +// Minimum server version: 10.7 +func (g *GroupService) GetSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, error) { + syncable, appErr := g.api.GetGroupSyncable(groupID, syncableID, syncableType) + return syncable, normalizeAppErr(appErr) +} + +// GetSyncables gets all group syncables for the given group. +// +// Minimum server version: 10.7 +func (g *GroupService) GetSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, error) { + syncables, appErr := g.api.GetGroupSyncables(groupID, syncableType) + return syncables, normalizeAppErr(appErr) +} + +// UpsertSyncable creates or updates a group syncable. +// +// Minimum server version: 10.7 +func (g *GroupService) UpsertSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, error) { + syncable, appErr := g.api.UpsertGroupSyncable(groupSyncable) + return syncable, normalizeAppErr(appErr) +} + +// UpdateSyncable updates a group syncable. +// +// Minimum server version: 10.7 +func (g *GroupService) UpdateSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, error) { + syncable, appErr := g.api.UpdateGroupSyncable(groupSyncable) + return syncable, normalizeAppErr(appErr) +} + +// DeleteSyncable deletes a group syncable. +// +// Minimum server version: 10.7 +func (g *GroupService) DeleteSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, error) { + syncable, appErr := g.api.DeleteGroupSyncable(groupID, syncableID, syncableType) + return syncable, normalizeAppErr(appErr) +} + +// GetGroups returns a list of all groups with the given options and restrictions. +// +// Minimum server version: 10.7 +func (g *GroupService) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, error) { + groups, appErr := g.api.GetGroups(page, perPage, opts, viewRestrictions) + return groups, normalizeAppErr(appErr) +} diff --git a/webapp/channels/src/components/add_groups_to_channel_modal/add_groups_to_channel_modal.tsx b/webapp/channels/src/components/add_groups_to_channel_modal/add_groups_to_channel_modal.tsx index 0d57931f46..9735fd5a21 100644 --- a/webapp/channels/src/components/add_groups_to_channel_modal/add_groups_to_channel_modal.tsx +++ b/webapp/channels/src/components/add_groups_to_channel_modal/add_groups_to_channel_modal.tsx @@ -7,7 +7,7 @@ import type {IntlShape} from 'react-intl'; import {injectIntl, FormattedMessage, defineMessage} from 'react-intl'; import type {ServerError} from '@mattermost/types/errors'; -import type {Group, SyncablePatch} from '@mattermost/types/groups'; +import type {Group, GroupSource, SyncablePatch} from '@mattermost/types/groups'; import {SyncableType} from '@mattermost/types/groups'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -37,7 +37,7 @@ export type Props = { onAddCallback?: (groupIDs: string[]) => void; actions: { - getGroupsNotAssociatedToChannel: (channelID: string, q?: string, page?: number, perPage?: number, filterParentTeamPermitted?: boolean) => Promise; + getGroupsNotAssociatedToChannel: (channelID: string, q?: string, page?: number, perPage?: number, filterParentTeamPermitted?: boolean, source?: GroupSource | string, onlySyncableSources?: boolean) => Promise; setModalSearchTerm: (term: string) => void; linkGroupSyncable: (groupID: string, syncableID: string, syncableType: SyncableType, patch: Partial) => Promise; getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference: boolean, includeMemberCount: boolean) => Promise; @@ -80,7 +80,7 @@ export class AddGroupsToChannelModal extends React.PureComponent { Promise.all([ this.props.actions.getTeam(this.props.teamID), this.props.actions.getAllGroupsAssociatedToTeam(this.props.teamID, false, true), - this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, '', 0, GROUPS_PER_PAGE + 1, true), + this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, '', 0, GROUPS_PER_PAGE + 1, true, '', true), this.props.actions.getAllGroupsAssociatedToChannel(this.props.currentChannelId, false, true), ]).then(() => { this.setGroupsLoadingState(false); @@ -99,7 +99,7 @@ export class AddGroupsToChannelModal extends React.PureComponent { this.searchTimeoutId = window.setTimeout( async () => { this.setGroupsLoadingState(true); - await this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, searchTerm, undefined, undefined, true); + await this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, searchTerm, undefined, undefined, true, '', true); this.setGroupsLoadingState(false); }, Constants.SEARCH_TIMEOUT_MILLISECONDS, @@ -168,7 +168,7 @@ export class AddGroupsToChannelModal extends React.PureComponent { handlePageChange = (page: number, prevPage: number) => { if (page > prevPage) { this.setGroupsLoadingState(true); - this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, this.props.searchTerm, page, GROUPS_PER_PAGE + 1, true).then(() => { + this.props.actions.getGroupsNotAssociatedToChannel(this.props.currentChannelId, this.props.searchTerm, page, GROUPS_PER_PAGE + 1, true, '', true).then(() => { this.setGroupsLoadingState(false); }); } diff --git a/webapp/channels/src/components/add_groups_to_team_modal/add_groups_to_team_modal.tsx b/webapp/channels/src/components/add_groups_to_team_modal/add_groups_to_team_modal.tsx index 7e0f8de904..0b9eb53257 100644 --- a/webapp/channels/src/components/add_groups_to_team_modal/add_groups_to_team_modal.tsx +++ b/webapp/channels/src/components/add_groups_to_team_modal/add_groups_to_team_modal.tsx @@ -7,7 +7,7 @@ import {Modal} from 'react-bootstrap'; import type {IntlShape} from 'react-intl'; import {injectIntl, FormattedMessage, defineMessage} from 'react-intl'; -import type {Group, SyncablePatch} from '@mattermost/types/groups'; +import type {Group, SyncablePatch, GroupSource} from '@mattermost/types/groups'; import {SyncableType} from '@mattermost/types/groups'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -41,7 +41,7 @@ type Props = { } export type Actions = { - getGroupsNotAssociatedToTeam: (teamID: string, q?: string, page?: number, perPage?: number) => Promise; + getGroupsNotAssociatedToTeam: (teamID: string, q?: string, page?: number, perPage?: number, source?: GroupSource | string, onlySyncableSources?: boolean) => Promise; setModalSearchTerm: (term: string) => void; linkGroupSyncable: (groupID: string, syncableID: string, syncableType: SyncableType, patch: SyncablePatch) => Promise; getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference: boolean, includeMemberCount: boolean) => Promise; @@ -79,7 +79,7 @@ export class AddGroupsToTeamModal extends React.PureComponent { public componentDidMount() { Promise.all([ - this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, '', 0, GROUPS_PER_PAGE + 1), + this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, '', 0, GROUPS_PER_PAGE + 1, '', true), this.props.actions.getAllGroupsAssociatedToTeam(this.props.currentTeamId, false, true), ]).then(() => { this.setGroupsLoadingState(false); @@ -98,7 +98,7 @@ export class AddGroupsToTeamModal extends React.PureComponent { this.searchTimeoutId = window.setTimeout( async () => { this.setGroupsLoadingState(true); - await this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, searchTerm); + await this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, searchTerm, 0, GROUPS_PER_PAGE + 1, '', true); this.setGroupsLoadingState(false); }, Constants.SEARCH_TIMEOUT_MILLISECONDS, @@ -176,7 +176,7 @@ export class AddGroupsToTeamModal extends React.PureComponent { public handlePageChange = (page: number, prevPage: number): void => { if (page > prevPage) { this.setGroupsLoadingState(true); - this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, this.props.searchTerm, page, GROUPS_PER_PAGE + 1).then(() => { + this.props.actions.getGroupsNotAssociatedToTeam(this.props.currentTeamId, this.props.searchTerm, page, GROUPS_PER_PAGE + 1, '', true).then(() => { this.setGroupsLoadingState(false); }); } diff --git a/webapp/channels/src/components/admin_console/group_settings/__snapshots__/group_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/group_settings/__snapshots__/group_settings.test.tsx.snap index ba831cf048..634d3a6e69 100644 --- a/webapp/channels/src/components/admin_console/group_settings/__snapshots__/group_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/group_settings/__snapshots__/group_settings.test.tsx.snap @@ -56,6 +56,9 @@ For more information on Groups, please see documentation." > + diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/__snapshots__/group_users.test.tsx.snap b/webapp/channels/src/components/admin_console/group_settings/group_details/__snapshots__/group_users.test.tsx.snap index 87ec290b5a..6c6caeb8db 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/__snapshots__/group_users.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/__snapshots__/group_users.test.tsx.snap @@ -1399,3 +1399,224 @@ exports[`components/admin_console/group_settings/group_details/GroupUsers should /> `; + +exports[`components/admin_console/group_settings/group_details/GroupUsers should match snapshot, plugin group 1`] = ` +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + +
+
+`; diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx index ffe4a08c6d..e15ef47edc 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx @@ -761,6 +761,7 @@ class GroupDetails extends React.PureComponent { total={memberCount} groupID={this.props.groupID} getMembers={this.props.actions.getMembers} + source={group.source} /> diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.test.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.test.tsx index 04a2d19630..a96fce1f39 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.test.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.test.tsx @@ -5,6 +5,7 @@ import {shallow} from 'enzyme'; import range from 'lodash/range'; import React from 'react'; +import {GroupSource, PluginGroupSourcePrefix} from '@mattermost/types/groups'; import type {UserProfile} from '@mattermost/types/users'; import GroupUsers from 'components/admin_console/group_settings/group_details/group_users'; @@ -23,6 +24,7 @@ describe('components/admin_console/group_settings/group_details/GroupUsers', () groupID: 'xxxxxxxxxxxxxxxxxxxxxxxxxx', members: members.slice(0, 20), total: 20, + source: GroupSource.Ldap, getMembers: jest.fn().mockReturnValue(Promise.resolve()), }; @@ -36,6 +38,16 @@ describe('components/admin_console/group_settings/group_details/GroupUsers', () expect(wrapper).toMatchSnapshot(); }); + test('should match snapshot, plugin group', () => { + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + test('should match snapshot, on loading with data', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.tsx index 96bb7ed406..322f42f895 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_users.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import {Link} from 'react-router-dom'; +import {GroupSource, PluginGroupSourcePrefix} from '@mattermost/types/groups'; import type {UserProfile} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -19,6 +20,7 @@ type Props = { groupID: string; members: UserProfile[]; total: number; + source?: string; getMembers: ( id: string, page?: number, @@ -154,23 +156,42 @@ export default class GroupUsers extends React.PureComponent { ); }; + renderHeader = () => { + if (this.props.source === GroupSource.Ldap) { + return ( + Click here to view' + } + values={{ + a: (chunks: string) => ( + + {chunks} + + ), + }} + /> + ); + } + + if (this.props.source?.startsWith(PluginGroupSourcePrefix.Plugin)) { + return ( + + ); + } + + return null; + }; + render = () => { return (
- Click here to view' - } - values={{ - a: (chunks: string) => ( - - {chunks} - - ), - }} - /> + {this.renderHeader()}
{ readOnly={isDisabled} /> +
diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx index 86f2604518..38658904cf 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_list_item/view_user_group_list_item.tsx @@ -4,12 +4,12 @@ import React, {useCallback} from 'react'; import {useSelector} from 'react-redux'; -import {GroupSource} from '@mattermost/types/groups'; import type {Group} from '@mattermost/types/groups'; import type {UserProfile} from '@mattermost/types/users'; import {getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; +import {isSyncableSource} from 'mattermost-redux/utils/group_utils'; import StatusIcon from 'components/status_icon'; import Avatar from 'components/widgets/users/avatar'; @@ -72,7 +72,7 @@ const ViewUserGroupListItem = (props: Props) => { {`@${user.username}`} { - (group.source.toLowerCase() !== GroupSource.Ldap && props.permissionToLeaveGroup) && + (!isSyncableSource(group.source.toLowerCase()) && props.permissionToLeaveGroup) &&