diff --git a/api/v4/source/remoteclusters.yaml b/api/v4/source/remoteclusters.yaml index 8c9b7400949..5e981f2cfd1 100644 --- a/api/v4/source/remoteclusters.yaml +++ b/api/v4/source/remoteclusters.yaml @@ -7,7 +7,7 @@ Get a list of remote clusters. ##### Permissions - `manage_secure_connections` + `manage_secure_connections` or `manage_shared_channels` operationId: GetRemoteClusters parameters: - name: page @@ -134,7 +134,7 @@ Get the Remote Cluster details from the provided id string. ##### Permissions - `manage_secure_connections` + `manage_secure_connections` or `manage_shared_channels` operationId: GetRemoteCluster parameters: - name: remote_id diff --git a/api/v4/source/sharedchannels.yaml b/api/v4/source/sharedchannels.yaml index b765b09eac4..346a3ded016 100644 --- a/api/v4/source/sharedchannels.yaml +++ b/api/v4/source/sharedchannels.yaml @@ -56,7 +56,7 @@ and their status. ##### Permissions - `manage_secure_connections` + `manage_secure_connections` or `manage_shared_channels` operationId: GetSharedChannelRemotesByRemoteCluster parameters: - name: remote_id diff --git a/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts b/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts index a33886ad841..7b9738ddf40 100644 --- a/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts @@ -834,94 +834,95 @@ test( }, ); -test( - 'message actions include Show translation', - { - tag: ['@autotranslation'], - }, - async ({pw}) => { - const {adminClient, user, userClient, team} = await pw.initSetup(); - - const license = await adminClient.getClientLicenseOld(); - test.skip( - !hasAutotranslationLicense(license.SkuShortName), - 'Skipping test - server does not have Entry or Advanced license', - ); - const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010'; - await enableAutotranslationConfig(adminClient, { - mockBaseUrl: translationUrl, - targetLanguages: ['en', 'es'], - }); - - const channelName = `autotranslation-dotmenu-${await getRandomId()}`; - const created = await adminClient.createChannel({ - team_id: team.id, - name: channelName, - display_name: 'Dot Menu Show Translation Test', - type: 'O', - }); - await enableChannelAutotranslation(adminClient, created.id); - await adminClient.addToChannel(user.id, created.id); - await setUserChannelAutotranslation(userClient, created.id, true); - - const poster = await pw.random.user('poster'); - const createdPoster = await adminClient.createUser(poster, '', ''); - await adminClient.addToTeam(team.id, createdPoster.id); - await adminClient.addToChannel(createdPoster.id, created.id); - const {client: posterClient} = await pw.makeClient({ - username: poster.username, - password: poster.password, - }); - if (!posterClient) throw new Error('Failed to create poster client'); - - // Create a second poster to show translation indicator (only visible with multiple users) - const poster2 = await pw.random.user('poster2'); - const createdPoster2 = await adminClient.createUser(poster2, '', ''); - await adminClient.addToTeam(team.id, createdPoster2.id); - await adminClient.addToChannel(createdPoster2.id, created.id); - const {client: posterClient2} = await pw.makeClient({ - username: poster2.username, - password: poster2.password, - }); - if (!posterClient2) throw new Error('Failed to create second poster client'); - - // Set Spanish source to ensure translation happens - await setMockSourceLanguage(translationUrl, 'es'); - // Post Spanish message that's long enough for reliable detection - await posterClient.createPost({ - channel_id: created.id, - message: 'Este mensaje es para probar el menú de acciones con la opción de mostrar traducción automática', - user_id: createdPoster.id, - }); - // Second user posts a message so the first user's translation indicator appears - await posterClient2.createPost({ - channel_id: created.id, - message: 'Segundo usuario con mensaje más largo para mejor detección de idioma', - user_id: createdPoster2.id, - }); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, channelName); - await channelsPage.toBeVisible(); - - // * Find post with message text and wait for translation before opening dot menu - const messagePost = channelsPage.centerView.container - .locator('[id^="post_"]') - .filter({hasText: 'Este mensaje es para probar el menú de acciones'}); - await messagePost.waitFor({state: 'visible', timeout: 15000}); - - // Wait for mock translation to be applied before opening the menu - // (mock appends "[translated to en]"; Show translation only appears after translation) - await expect(messagePost.getByText(/\[translated to en\]/i)).toBeVisible({timeout: 15000}); - - await messagePost.hover(); - // Click the "more" (three dots) button to open the action menu - await messagePost.locator('.post-menu').getByRole('button', {name: 'more'}).click(); - - const showTranslationItem = page.getByRole('menuitem', {name: 'Show translation'}); - await expect(showTranslationItem).toBeVisible({timeout: 10000}); - }, -); +// Skipped due to flaky race condition - see https://github.com/mattermost/mattermost/pull/35443 +// test( +// 'message actions include Show translation', +// { +// tag: ['@autotranslation'], +// }, +// async ({pw}) => { +// const {adminClient, user, userClient, team} = await pw.initSetup(); +// +// const license = await adminClient.getClientLicenseOld(); +// test.skip( +// !hasAutotranslationLicense(license.SkuShortName), +// 'Skipping test - server does not have Entry or Advanced license', +// ); +// const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010'; +// await enableAutotranslationConfig(adminClient, { +// mockBaseUrl: translationUrl, +// targetLanguages: ['en', 'es'], +// }); +// +// const channelName = `autotranslation-dotmenu-${await getRandomId()}`; +// const created = await adminClient.createChannel({ +// team_id: team.id, +// name: channelName, +// display_name: 'Dot Menu Show Translation Test', +// type: 'O', +// }); +// await enableChannelAutotranslation(adminClient, created.id); +// await adminClient.addToChannel(user.id, created.id); +// await setUserChannelAutotranslation(userClient, created.id, true); +// +// const poster = await pw.random.user('poster'); +// const createdPoster = await adminClient.createUser(poster, '', ''); +// await adminClient.addToTeam(team.id, createdPoster.id); +// await adminClient.addToChannel(createdPoster.id, created.id); +// const {client: posterClient} = await pw.makeClient({ +// username: poster.username, +// password: poster.password, +// }); +// if (!posterClient) throw new Error('Failed to create poster client'); +// +// // Create a second poster to show translation indicator (only visible with multiple users) +// const poster2 = await pw.random.user('poster2'); +// const createdPoster2 = await adminClient.createUser(poster2, '', ''); +// await adminClient.addToTeam(team.id, createdPoster2.id); +// await adminClient.addToChannel(createdPoster2.id, created.id); +// const {client: posterClient2} = await pw.makeClient({ +// username: poster2.username, +// password: poster2.password, +// }); +// if (!posterClient2) throw new Error('Failed to create second poster client'); +// +// // Set Spanish source to ensure translation happens +// await setMockSourceLanguage(translationUrl, 'es'); +// // Post Spanish message that's long enough for reliable detection +// await posterClient.createPost({ +// channel_id: created.id, +// message: 'Este mensaje es para probar el menú de acciones con la opción de mostrar traducción automática', +// user_id: createdPoster.id, +// }); +// // Second user posts a message so the first user's translation indicator appears +// await posterClient2.createPost({ +// channel_id: created.id, +// message: 'Segundo usuario con mensaje más largo para mejor detección de idioma', +// user_id: createdPoster2.id, +// }); +// +// const {channelsPage, page} = await pw.testBrowser.login(user); +// await channelsPage.goto(team.name, channelName); +// await channelsPage.toBeVisible(); +// +// // * Find post with message text and wait for translation before opening dot menu +// const messagePost = channelsPage.centerView.container +// .locator('[id^="post_"]') +// .filter({hasText: 'Este mensaje es para probar el menú de acciones'}); +// await messagePost.waitFor({state: 'visible', timeout: 15000}); +// +// // Wait for mock translation to be applied before opening the menu +// // (mock appends "[translated to en]"; Show translation only appears after translation) +// await expect(messagePost.getByText(/\[translated to en\]/i)).toBeVisible({timeout: 15000}); +// +// await messagePost.hover(); +// // Click the "more" (three dots) button to open the action menu +// await messagePost.locator('.post-menu').getByRole('button', {name: 'more'}).click(); +// +// const showTranslationItem = page.getByRole('menuitem', {name: 'Show translation'}); +// await expect(showTranslationItem).toBeVisible({timeout: 10000}); +// }, +// ); test( 'any user can disable and enable again autotranslation for themselves in a channel', diff --git a/server/channels/api4/remote_cluster.go b/server/channels/api4/remote_cluster.go index 3816c24a43d..7f615257f37 100644 --- a/server/channels/api4/remote_cluster.go +++ b/server/channels/api4/remote_cluster.go @@ -318,8 +318,8 @@ func remoteSetProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { } func getRemoteClusters(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnectionsOrSharedChannels() + if c.Err != nil { return } @@ -364,8 +364,8 @@ func getRemoteClusters(c *Context, w http.ResponseWriter, r *http.Request) { } func createRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnections() + if c.Err != nil { return } @@ -451,8 +451,8 @@ func createRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { } func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnections() + if c.Err != nil { return } @@ -530,13 +530,13 @@ func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Reques } func generateRemoteClusterInvite(c *Context, w http.ResponseWriter, r *http.Request) { - c.RequireRemoteId() + c.RequirePermissionToManageSecureConnections() if c.Err != nil { return } - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequireRemoteId() + if c.Err != nil { return } @@ -589,8 +589,8 @@ func generateRemoteClusterInvite(c *Context, w http.ResponseWriter, r *http.Requ } func getRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnectionsOrSharedChannels() + if c.Err != nil { return } @@ -618,8 +618,8 @@ func getRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { } func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnections() + if c.Err != nil { return } @@ -669,13 +669,13 @@ func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { } func deleteRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { - c.RequireRemoteId() + c.RequirePermissionToManageSecureConnections() if c.Err != nil { return } - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequireRemoteId() + if c.Err != nil { return } diff --git a/server/channels/api4/remote_cluster_test.go b/server/channels/api4/remote_cluster_test.go index 015ec4786b9..7b649cf3d95 100644 --- a/server/channels/api4/remote_cluster_test.go +++ b/server/channels/api4/remote_cluster_test.go @@ -50,6 +50,44 @@ func TestGetRemoteClustersWithSecureConnectionManagerRole(t *testing.T) { }) } +func TestGetRemoteClustersWithSharedChannelManagerRole(t *testing.T) { + mainHelper.Parallel(t) + th := setupForSharedChannels(t).InitBasic(t) + + // Create a remote cluster for testing + newRC := &model.RemoteCluster{ + RemoteId: model.NewId(), + Name: "test-remote", + SiteURL: "http://example.com", + CreatorId: th.SystemAdminUser.Id, + Token: model.NewId(), + } + _, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + + // Create a user with only the shared_channel_manager role + scmUser := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, scmUser.Id, model.SystemUserRoleId+" "+model.SharedChannelManagerRoleId, false) + require.Nil(t, appErr) + + scmClient := th.CreateClient() + _, _, err := scmClient.Login(context.Background(), scmUser.Email, scmUser.Password) + require.NoError(t, err) + + t.Run("regular user should be denied", func(t *testing.T) { + _, resp, err := th.Client.GetRemoteClusters(context.Background(), 0, 999999, model.RemoteClusterQueryFilter{}) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + t.Run("shared_channel_manager user should have access", func(t *testing.T) { + rcs, resp, err := scmClient.GetRemoteClusters(context.Background(), 0, 999999, model.RemoteClusterQueryFilter{}) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, rcs) + }) +} + func TestCreateRemoteClusterWithSecureConnectionManagerRole(t *testing.T) { mainHelper.Parallel(t) th := setupForSharedChannels(t).InitBasic(t) @@ -86,6 +124,34 @@ func TestCreateRemoteClusterWithSecureConnectionManagerRole(t *testing.T) { }) } +func TestCreateRemoteClusterDeniedForSharedChannelManagerRole(t *testing.T) { + mainHelper.Parallel(t) + th := setupForSharedChannels(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" }) + + // Create a user with only the shared_channel_manager role + scmUser := th.CreateUser(t) + _, appErr := th.App.UpdateUserRoles(th.Context, scmUser.Id, model.SystemUserRoleId+" "+model.SharedChannelManagerRoleId, false) + require.Nil(t, appErr) + + scmClient := th.CreateClient() + _, _, err := scmClient.Login(context.Background(), scmUser.Email, scmUser.Password) + require.NoError(t, err) + + t.Run("shared_channel_manager should be denied create", func(t *testing.T) { + rcPayload := &model.RemoteClusterWithPassword{ + RemoteCluster: &model.RemoteCluster{ + Name: "test-from-scm", + DefaultTeamId: th.BasicTeam.Id, + }, + Password: "mysupersecret", + } + _, resp, err := scmClient.CreateRemoteCluster(context.Background(), rcPayload) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) +} + func TestGetRemoteClusters(t *testing.T) { mainHelper.Parallel(t) t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { @@ -608,6 +674,63 @@ func TestGetRemoteCluster(t *testing.T) { }) } +func TestGetRemoteClusterWithManagerRoles(t *testing.T) { + mainHelper.Parallel(t) + th := setupForSharedChannels(t).InitBasic(t) + + // Create a remote cluster for testing + newRC := &model.RemoteCluster{ + RemoteId: model.NewId(), + Name: "test-remote", + SiteURL: "http://example.com", + CreatorId: th.SystemAdminUser.Id, + DefaultTeamId: th.BasicTeam.Id, + Token: model.NewId(), + } + _, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + + // Create a user with only the shared_channel_manager role + sharedChannelUser := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, sharedChannelUser.Id, model.SystemUserRoleId+" "+model.SharedChannelManagerRoleId, false) + require.Nil(t, appErr) + + sharedChannelClient := th.CreateClient() + _, _, err := sharedChannelClient.Login(context.Background(), sharedChannelUser.Email, sharedChannelUser.Password) + require.NoError(t, err) + + // Create a user with only the secure_connection_manager role + secureConnUser := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, secureConnUser.Id, model.SystemUserRoleId+" "+model.SecureConnectionManagerRoleId, false) + require.Nil(t, appErr) + + secureConnClient := th.CreateClient() + _, _, err = secureConnClient.Login(context.Background(), secureConnUser.Email, secureConnUser.Password) + require.NoError(t, err) + + t.Run("regular user should be denied", func(t *testing.T) { + _, resp, err := th.Client.GetRemoteCluster(context.Background(), newRC.RemoteId) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + t.Run("shared_channel_manager user should have access", func(t *testing.T) { + fetchedRC, resp, err := sharedChannelClient.GetRemoteCluster(context.Background(), newRC.RemoteId) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, newRC.RemoteId, fetchedRC.RemoteId) + require.Empty(t, fetchedRC.Token) + }) + + t.Run("secure_connection_manager user should have access", func(t *testing.T) { + fetchedRC, resp, err := secureConnClient.GetRemoteCluster(context.Background(), newRC.RemoteId) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, newRC.RemoteId, fetchedRC.RemoteId) + require.Empty(t, fetchedRC.Token) + }) +} + func TestPatchRemoteCluster(t *testing.T) { mainHelper.Parallel(t) newRC := &model.RemoteCluster{ diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index 2586c755170..9620d5863d0 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -104,8 +104,8 @@ func getSharedChannelRemotesByRemoteCluster(c *Context, w http.ResponseWriter, r return } - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSecureConnections) + c.RequirePermissionToManageSecureConnectionsOrSharedChannels() + if c.Err != nil { return } @@ -150,8 +150,8 @@ func inviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Req return } - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSharedChannels) + c.RequirePermissionToManageSharedChannels() + if c.Err != nil { return } @@ -201,8 +201,8 @@ func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.R return } - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { - c.SetPermissionError(model.PermissionManageSharedChannels) + c.RequirePermissionToManageSharedChannels() + if c.Err != nil { return } diff --git a/server/channels/api4/shared_channel_test.go b/server/channels/api4/shared_channel_test.go index 3c49e544223..6a4aa729817 100644 --- a/server/channels/api4/shared_channel_test.go +++ b/server/channels/api4/shared_channel_test.go @@ -669,3 +669,66 @@ func TestUninviteRemoteClusterToChannel(t *testing.T) { t.Skip("Requires server2server communication: ToBeImplemented") }) } + +func TestSharedChannelEndpointsWithSharedChannelManagerRole(t *testing.T) { + mainHelper.Parallel(t) + th := setupForSharedChannels(t).InitBasic(t) + + newRC := &model.RemoteCluster{Name: "rc", SiteURL: "http://example.com", CreatorId: th.SystemAdminUser.Id} + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + + // Create a user with only the shared_channel_manager role + scmUser := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, scmUser.Id, model.SystemUserRoleId+" "+model.SharedChannelManagerRoleId, false) + require.Nil(t, appErr) + + scmClient := th.CreateClient() + _, _, err := scmClient.Login(context.Background(), scmUser.Email, scmUser.Password) + require.NoError(t, err) + + t.Run("getSharedChannelRemotesByRemoteCluster should allow shared_channel_manager", func(t *testing.T) { + _, resp, err := scmClient.GetSharedChannelRemotesByRemoteCluster(context.Background(), rc.RemoteId, model.SharedChannelRemoteFilterOpts{}, 0, 100) + CheckOKStatus(t, resp) + require.NoError(t, err) + }) + + t.Run("inviteRemoteClusterToChannel should allow shared_channel_manager", func(t *testing.T) { + // This will fail with a bad request (nonexistent channel) rather than forbidden, + // which proves the permission check passed. + resp, err := scmClient.InviteRemoteClusterToChannel(context.Background(), rc.RemoteId, model.NewId()) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + }) + + t.Run("uninviteRemoteClusterToChannel should allow shared_channel_manager", func(t *testing.T) { + // Same as invite — a bad request proves the permission check passed. + resp, err := scmClient.UninviteRemoteClusterToChannel(context.Background(), rc.RemoteId, model.NewId()) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + }) +} + +func TestGetSharedChannelRemotesByRemoteClusterWithSecureConnectionManagerRole(t *testing.T) { + mainHelper.Parallel(t) + th := setupForSharedChannels(t).InitBasic(t) + + newRC := &model.RemoteCluster{Name: "rc", SiteURL: "http://example.com", CreatorId: th.SystemAdminUser.Id} + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + + // Create a user with only the secure_connection_manager role + scmUser := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, scmUser.Id, model.SystemUserRoleId+" "+model.SecureConnectionManagerRoleId, false) + require.Nil(t, appErr) + + scmClient := th.CreateClient() + _, _, err := scmClient.Login(context.Background(), scmUser.Email, scmUser.Password) + require.NoError(t, err) + + t.Run("secure_connection_manager should have access", func(t *testing.T) { + _, resp, err := scmClient.GetSharedChannelRemotesByRemoteCluster(context.Background(), rc.RemoteId, model.SharedChannelRemoteFilterOpts{}, 0, 100) + CheckOKStatus(t, resp) + require.NoError(t, err) + }) +} diff --git a/server/channels/web/context.go b/server/channels/web/context.go index 9cb57428ab7..ef2babadd64 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -779,6 +779,42 @@ func (c *Context) RequireRecapId() *Context { return c } +func (c *Context) RequirePermissionToManageSecureConnections() *Context { + if c.Err != nil { + return c + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + } + return c +} + +func (c *Context) RequirePermissionToManageSharedChannels() *Context { + if c.Err != nil { + return c + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSharedChannels) { + c.SetPermissionError(model.PermissionManageSharedChannels) + } + return c +} + +func (c *Context) RequirePermissionToManageSecureConnectionsOrSharedChannels() *Context { + if c.Err != nil { + return c + } + + if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), []*model.Permission{ + model.PermissionManageSecureConnections, + model.PermissionManageSharedChannels, + }) { + c.SetPermissionError(model.PermissionManageSecureConnections, model.PermissionManageSharedChannels) + } + return c +} + func (c *Context) GetRemoteID(r *http.Request) string { return r.Header.Get(model.HeaderRemoteclusterId) } diff --git a/webapp/channels/src/components/admin_console/system_roles/strings.tsx b/webapp/channels/src/components/admin_console/system_roles/strings.tsx index c637ad18fc4..fb6aefbf730 100644 --- a/webapp/channels/src/components/admin_console/system_roles/strings.tsx +++ b/webapp/channels/src/components/admin_console/system_roles/strings.tsx @@ -81,7 +81,7 @@ export const rolesStrings: Record> = { }, description: { id: 'admin.permissions.roles.shared_channel_manager.description', - defaultMessage: 'Can share and unshare channels with existing connections to remote servers.', + defaultMessage: 'Can browse available connections and share or unshare channels with remote servers.', }, type: { id: 'admin.permissions.roles.shared_channel_manager.type', diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_permissions.tsx b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_permissions.tsx index 0cde299570c..3cd01c9cdf4 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_permissions.tsx +++ b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_permissions.tsx @@ -249,7 +249,7 @@ export default class SystemRolePermissions extends React.PureComponent ( {chunks}, }} diff --git a/webapp/channels/src/components/recaps/recaps.test.tsx b/webapp/channels/src/components/recaps/recaps.test.tsx new file mode 100644 index 00000000000..c5a45698987 --- /dev/null +++ b/webapp/channels/src/components/recaps/recaps.test.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {MemoryRouter} from 'react-router-dom'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import {LhsItemType, LhsPage} from 'types/store/lhs'; + +import Recaps from './recaps'; + +const mockDispatch = jest.fn(); +const mockGetAgents = jest.fn(() => ({type: 'GET_AGENTS'})); +const mockGetRecaps = jest.fn((page: number, perPage: number) => ({type: 'GET_RECAPS', meta: {page, perPage}})); +const mockSelectLhsItem = jest.fn((type: string, id?: string) => { + return {type: 'SELECT_LHS_ITEM', meta: {lhsType: type, id}}; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux') as typeof import('react-redux'), + useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +jest.mock('mattermost-redux/actions/agents', () => ({ + getAgents: () => mockGetAgents(), +})); + +jest.mock('mattermost-redux/actions/recaps', () => ({ + getRecaps: (page: number, perPage: number) => mockGetRecaps(page, perPage), +})); + +jest.mock('mattermost-redux/selectors/entities/recaps', () => ({ + getUnreadRecaps: jest.fn(() => []), + getReadRecaps: jest.fn(() => []), +})); + +jest.mock('actions/views/lhs', () => ({ + selectLhsItem: (type: string, id?: string) => mockSelectLhsItem(type, id), +})); + +jest.mock('actions/views/modals', () => ({ + openModal: jest.fn(() => ({type: 'OPEN_MODAL'})), +})); + +jest.mock('components/common/hooks/useGetAgentsBridgeEnabled', () => jest.fn(() => ({available: true}))); +jest.mock('components/common/hooks/useGetFeatureFlagValue', () => jest.fn(() => 'true')); +jest.mock('components/create_recap_modal', () => () =>
); +jest.mock('./recaps_list', () => ({__esModule: true, default: () =>
})); + +describe('components/recaps/Recaps', () => { + beforeEach(() => { + mockDispatch.mockClear(); + mockGetAgents.mockClear(); + mockGetRecaps.mockClear(); + mockSelectLhsItem.mockClear(); + }); + + test('selects Recaps in the LHS on mount', () => { + renderWithContext( + + + , + ); + + expect(mockSelectLhsItem).toHaveBeenCalledWith(LhsItemType.Page, LhsPage.Recaps); + expect(mockGetRecaps).toHaveBeenCalledWith(0, 60); + expect(mockGetAgents).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'SELECT_LHS_ITEM'})); + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'GET_RECAPS'})); + expect(mockDispatch).toHaveBeenCalledWith({type: 'GET_AGENTS'}); + }); +}); diff --git a/webapp/channels/src/components/recaps/recaps.tsx b/webapp/channels/src/components/recaps/recaps.tsx index 6b83f0beff1..eba7e8a55c4 100644 --- a/webapp/channels/src/components/recaps/recaps.tsx +++ b/webapp/channels/src/components/recaps/recaps.tsx @@ -12,6 +12,7 @@ import {getAgents} from 'mattermost-redux/actions/agents'; import {getRecaps} from 'mattermost-redux/actions/recaps'; import {getUnreadRecaps, getReadRecaps} from 'mattermost-redux/selectors/entities/recaps'; +import {selectLhsItem} from 'actions/views/lhs'; import {openModal} from 'actions/views/modals'; import useGetAgentsBridgeEnabled from 'components/common/hooks/useGetAgentsBridgeEnabled'; @@ -20,6 +21,8 @@ import CreateRecapModal from 'components/create_recap_modal'; import {ModalIdentifiers} from 'utils/constants'; +import {LhsItemType, LhsPage} from 'types/store/lhs'; + import RecapsList from './recaps_list'; import './recaps.scss'; @@ -35,6 +38,7 @@ const Recaps = () => { const readRecaps = useSelector(getReadRecaps); useEffect(() => { + dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Recaps)); dispatch(getRecaps(0, 60)); dispatch(getAgents()); }, [dispatch]); diff --git a/webapp/channels/src/components/recaps_link/recaps_link.scss b/webapp/channels/src/components/recaps_link/recaps_link.scss index f2b4912e4f3..7f9e2705eb5 100644 --- a/webapp/channels/src/components/recaps_link/recaps_link.scss +++ b/webapp/channels/src/components/recaps_link/recaps_link.scss @@ -44,6 +44,10 @@ margin-right: 4px; font-size: 18px; opacity: 0.64; + + svg { + vertical-align: middle; + } } .SidebarChannelLinkLabel_wrapper { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ff087cbe57b..50e86ac1d70 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2124,10 +2124,10 @@ "admin.permissions.roles.secure_connection_manager.name": "Secure Connection Manager", "admin.permissions.roles.secure_connection_manager.permissions_info": "This role has the manage_secure_connections permission, which allows creating, editing, and deleting secure connections to remote servers.", "admin.permissions.roles.secure_connection_manager.type": "System Role", - "admin.permissions.roles.shared_channel_manager.description": "Can share and unshare channels with existing connections to remote servers.", - "admin.permissions.roles.shared_channel_manager.introduction": "The built-in Shared Channel Manager role can be used to delegate the ability to share and unshare channels with existing connections to remote servers to users other than the System Admin.", + "admin.permissions.roles.shared_channel_manager.description": "Can browse available connections and share or unshare channels with remote servers.", + "admin.permissions.roles.shared_channel_manager.introduction": "The built-in Shared Channel Manager role can be used to delegate the ability to browse available connections and share or unshare channels with remote servers to users other than the System Admin.", "admin.permissions.roles.shared_channel_manager.name": "Shared Channel Manager", - "admin.permissions.roles.shared_channel_manager.permissions_info": "This role has the manage_shared_channels permission, which allows sharing and unsharing channels with existing connections to remote servers.", + "admin.permissions.roles.shared_channel_manager.permissions_info": "This role has the manage_shared_channels permission, which allows browsing available connections and sharing or unsharing channels with remote servers.", "admin.permissions.roles.shared_channel_manager.type": "System Role", "admin.permissions.roles.system_admin.description": "Access to modifying everything.", "admin.permissions.roles.system_admin.name": "System Admin", diff --git a/webapp/channels/src/plugins/export.ts b/webapp/channels/src/plugins/export.ts index eb2d571bfc5..7dbdcc5fec7 100644 --- a/webapp/channels/src/plugins/export.ts +++ b/webapp/channels/src/plugins/export.ts @@ -24,7 +24,7 @@ import {ModalIdentifiers} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import messageHtmlToComponent from 'utils/message_html_to_component'; import * as NotificationSounds from 'utils/notification_sounds'; -import {sendToParent, onMessageFromParent, isPopoutWindow, canPopout} from 'utils/popouts/popout_windows'; +import {sendToParent, onMessageFromParent, isPopoutWindow, canPopout, popoutRhsPlugin} from 'utils/popouts/popout_windows'; import {formatText} from 'utils/text_formatting'; import {useWebSocket, useWebSocketClient, WebSocketContext} from 'utils/use_websocket'; import {imageURLForUser} from 'utils/utils'; @@ -72,6 +72,7 @@ interface WindowWithLibraries { onMessageFromParent: typeof onMessageFromParent; isPopoutWindow: typeof isPopoutWindow; canPopout: typeof canPopout; + popoutRhsPlugin: typeof popoutRhsPlugin; }; }; loadSharedDependency(request: string): unknown; @@ -150,6 +151,7 @@ window.WebappUtils = { onMessageFromParent, isPopoutWindow, canPopout, + popoutRhsPlugin, }, }; window.loadSharedDependency = loadSharedDependency; diff --git a/webapp/channels/src/types/store/lhs.ts b/webapp/channels/src/types/store/lhs.ts index d6b21475f8c..cde1fd2af68 100644 --- a/webapp/channels/src/types/store/lhs.ts +++ b/webapp/channels/src/types/store/lhs.ts @@ -20,6 +20,7 @@ export enum LhsItemType { export enum LhsPage { Drafts = 'drafts', + Recaps = 'recaps', Threads = 'threads', }