From bedb50f4eb4b3f798054c7c4101934a78775e22d Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 11 Mar 2026 10:04:16 -0400 Subject: [PATCH 1/4] Clear channel selection when opening Recaps (#35552) Co-authored-by: Cursor Agent --- .../src/components/recaps/recaps.test.tsx | 74 +++++++++++++++++++ .../channels/src/components/recaps/recaps.tsx | 4 + webapp/channels/src/types/store/lhs.ts | 1 + 3 files changed, 79 insertions(+) create mode 100644 webapp/channels/src/components/recaps/recaps.test.tsx 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/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', } From 15384788f6832209fc34908e57cee0c259c0d028 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 11 Mar 2026 09:44:32 -0600 Subject: [PATCH 2/4] Expose popoutRhsPlugin via WebappUtils.popouts for plugin access (#35483) Allows plugins to programmatically trigger their RHS panel to pop out into a separate window by calling window.WebappUtils.popouts.popoutRhsPlugin(). Co-authored-by: Claude Opus 4.6 (1M context) --- webapp/channels/src/plugins/export.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; From 67bf040bde9a2a508203d926629f7b20c6da348a Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 11 Mar 2026 13:07:42 -0400 Subject: [PATCH 3/4] MM-67795 fix Recaps sidebar icon alignment (#35546) Co-authored-by: Cursor Agent --- webapp/channels/src/components/recaps_link/recaps_link.scss | 4 ++++ 1 file changed, 4 insertions(+) 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 { From 162ed1bacd32f2384cc4c5e91a95deceaf7426ef Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Wed, 11 Mar 2026 15:53:06 -0400 Subject: [PATCH 4/4] MM-67684 Separate shared channel permissions from secure connection permissions (#35409) * Channel sharing operations (invite, uninvite, list shared channel remotes) now require ManageSharedChannels instead of ManageSecureConnections, allowing customers to delegate channel sharing without granting full connection management access. Endpoints serving both roles (getRemoteClusters, getSharedChannelRemotesByRemoteCluster) accept either permission. Also adds RequirePermission helpers on Context to reduce boilerplate across all remote cluster and shared channel handlers, and fixes a bug where invite/uninvite checked ManageSecureConnections but reported ManageSharedChannels in the error. --- api/v4/source/remoteclusters.yaml | 4 +- api/v4/source/sharedchannels.yaml | 2 +- .../autotranslation/autotranslation.spec.ts | 177 +++++++++--------- server/channels/api4/remote_cluster.go | 32 ++-- server/channels/api4/remote_cluster_test.go | 123 ++++++++++++ server/channels/api4/shared_channel.go | 12 +- server/channels/api4/shared_channel_test.go | 63 +++++++ server/channels/web/context.go | 36 ++++ .../admin_console/system_roles/strings.tsx | 2 +- .../system_role/system_role_permissions.tsx | 4 +- webapp/channels/src/i18n/en.json | 6 +- 11 files changed, 342 insertions(+), 119 deletions(-) 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/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",