From f6a29d82edb9799ece9a674bac7cdf4748e73075 Mon Sep 17 00:00:00 2001 From: Ben Cooke Date: Thu, 12 Mar 2026 10:07:00 -0400 Subject: [PATCH 1/5] [MM-67895] Fix Autotranslation e2e (#35563) --- .../ui/components/channels/post_dot_menu.ts | 2 + .../autotranslation/autotranslation.spec.ts | 182 +++++++++--------- 2 files changed, 95 insertions(+), 89 deletions(-) diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post_dot_menu.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post_dot_menu.ts index 6c8b20b22c3..7df116840d6 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post_dot_menu.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post_dot_menu.ts @@ -21,6 +21,7 @@ export default class PostDotMenu { readonly copyTextMenuItem; readonly deleteMenuItem; readonly flagMessageMenuItem; + readonly showTranslationMenuItem; constructor(container: Locator) { this.container = container; @@ -42,6 +43,7 @@ export default class PostDotMenu { this.copyTextMenuItem = getMenuItem('Copy Text'); this.deleteMenuItem = getMenuItem('Delete'); this.flagMessageMenuItem = getMenuItem('Quarantine for Review'); + this.showTranslationMenuItem = getMenuItem('Show translation'); } async toBeVisible() { 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 7b9738ddf40..f2f4cd1064a 100644 --- a/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/autotranslation/autotranslation.spec.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import { + ChannelsPost, disableChannelAutotranslation, enableAutotranslationConfig, enableChannelAutotranslation, @@ -834,95 +835,98 @@ test( }, ); -// 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( + '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 the target post and wait for its translation before opening the menu + const messagePost = channelsPage.centerView.container + .getByTestId('postView') + .filter({hasText: 'Este mensaje es para probar el menú de acciones'}); + await messagePost.waitFor({state: 'visible', timeout: 15000}); + await expect(messagePost.getByText(/\[translated to en\]/i)).toBeVisible({timeout: 15000}); + + // * Open dot menu using the established hover → wait → click pattern + const post = new ChannelsPost(messagePost); + await post.hover(); + await post.postMenu.toBeVisible(); + await post.postMenu.dotMenuButton.click(); + + // Move mouse away so it doesn't hover over Remind and trigger its submenu. + // The submenu's MUI portal sets aria-hidden on the main menu, breaking getByRole. + await page.mouse.move(0, 0); + await channelsPage.postDotMenu.toBeVisible(); + + // * Verify the "Show translation" menu item is present + await expect(channelsPage.postDotMenu.showTranslationMenuItem).toBeVisible({timeout: 10000}); + }, +); test( 'any user can disable and enable again autotranslation for themselves in a channel', From 52858082fe5e941ca5a4f294713d3ed4ffbb99b1 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:02:29 +0530 Subject: [PATCH 2/5] Anonymous URLs (#35493) * COmposing messages with redacted URLs * Handled non member channels * Some refinements * Optimizations * lint fixes * cleaned up hasObfuscatedSlug test * Fixed a test * Added system console setting * WIP * fixed channel seelection double selection bug * LInt fixes * i18n fixes * fixed test * CI * renamed setting * lint fixes * lint fixes * WIP * Combined TeamSignupDisplayNamePage and TeamUrl component into a single CreateTeamForm component * Converted CreateTeamForm to functional component * Refactored to mnake code cleaner * Handle team creation with setting enabled * Skipped team URL step if secure URL feature is enabled * Managed button text and steps in team creation flow * lint fixes * Don't register team URL path when using secure URL * Display team display name instead of name in system console top nav bar * Fixed tests * Fixed coderabbit issues * Fixed type errors * Optimization * improvements * Handled API errors during team creation when using secure URL setting * Some refinements * Added test * Updaetd tests, and trimming when reading instead of writing * Added tests for new components * Added BackstageNavbar tests * Restored package lock * lint fix * Updaetd plugin API * Updated team creation tests * Added tests for ChannelNameFormField * Added plugin API tests * Updated API terst * Review fixes * Added test for ConvertGmToChannelModal component * Added EA license check for secure urls feature * restored package lock * Fixed GM conversion test * Fixed team creation tests * remove message composition changes * remove message composition changes * remove message composition changes * restored a file * lint fix * renamed feature * used model.SafeDereference * Added E2E tests * add secure URL Playwright coverage Expand the secure URLs Playwright coverage to validate creation, routing, rename flows, system console configuration, and search/navigation behavior while adding the page objects needed to keep the tests maintainable. Made-with: Cursor * rename secure URLs copy to anonymous URLs Align the admin console and Playwright coverage with the Anonymous URLs feature name while preserving the existing UseAnonymousURLs config behavior and validating the renamed test surfaces. Made-with: Cursor * Update team creation CTA for anonymous URLs Show Create in the single-step anonymous URL flow while preserving Next and Finish in the standard team creation flow. Update unit and Playwright coverage to match the revised create-team UX. Made-with: Cursor --------- Co-authored-by: maria.nunez Co-authored-by: Mattermost Build --- .../lib/src/server/default_config.ts | 1 + .../channel_settings_modal.ts | 7 + .../channel_settings/info_settings.ts | 8 + .../components/channels/create_team_form.ts | 60 ++ .../channels/find_channels_modal.ts | 8 + .../lib/src/ui/components/channels/header.ts | 13 + .../components/channels/new_channel_modal.ts | 45 + .../playwright/lib/src/ui/components/index.ts | 9 + .../site_configuration/users_and_teams.ts | 36 + .../playwright/lib/src/ui/pages/channels.ts | 44 +- .../lib/src/ui/pages/system_console.ts | 3 + .../anonymous_urls/anonymous_urls.spec.ts | 900 ++++++++++++++++++ server/channels/api4/channel.go | 4 + server/channels/api4/channel_test.go | 40 + server/channels/api4/team.go | 6 + server/channels/api4/team_test.go | 41 + server/channels/app/plugin_api.go | 9 + server/channels/app/plugin_api_test.go | 189 ++++ server/config/client.go | 1 + server/config/client_test.go | 13 + server/public/model/config.go | 5 + .../channels/src/actions/views/drafts.test.ts | 3 + .../admin_console/admin_definition.tsx | 9 + .../components/backstage_navbar.test.tsx | 67 ++ .../backstage/components/backstage_navbar.tsx | 2 +- .../channel_name_form_field.test.tsx | 82 ++ .../channel_name_form_field.tsx | 30 +- .../convert_gm_to_channel_modal.test.tsx | 60 +- .../__snapshots__/create_team.test.tsx.snap | 1 + .../__snapshots__/display_name.test.tsx.snap | 75 -- .../create_team_form.test.tsx.snap | 164 ++++ .../create_team_form.test.tsx | 378 ++++++++ .../create_team_form/create_team_form.tsx | 316 ++++++ .../display_name_step.test.tsx | 97 ++ .../create_team_form/display_name_step.tsx | 92 ++ .../{team_url => create_team_form}/index.ts | 4 +- .../create_team_form/team_url_step.test.tsx | 114 +++ .../create_team_form/team_url_step.tsx | 140 +++ .../components/display_name.test.tsx | 68 -- .../create_team/components/display_name.tsx | 126 --- .../__snapshots__/team_url.test.tsx.snap | 89 -- .../components/team_url/team_url.test.tsx | 151 --- .../components/team_url/team_url.tsx | 317 ------ .../create_team/create_team.test.tsx | 34 + .../components/create_team/create_team.tsx | 32 +- .../src/components/create_team/index.ts | 5 + .../src/components/dot_menu/dot_menu.tsx | 2 +- .../new_channel_modal.test.tsx | 12 +- .../suggestion/suggestion_list_contents.tsx | 1 - webapp/channels/src/i18n/en.json | 3 + webapp/channels/src/selectors/config.ts | 18 + .../src/utils/admin_console_index.test.tsx | 2 +- webapp/platform/types/src/config.ts | 2 + 53 files changed, 3067 insertions(+), 871 deletions(-) create mode 100644 e2e-tests/playwright/lib/src/ui/components/channels/create_team_form.ts create mode 100644 e2e-tests/playwright/lib/src/ui/components/channels/new_channel_modal.ts create mode 100644 e2e-tests/playwright/lib/src/ui/components/system_console/sections/site_configuration/users_and_teams.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/anonymous_urls/anonymous_urls.spec.ts create mode 100644 webapp/channels/src/components/backstage/components/backstage_navbar.test.tsx create mode 100644 webapp/channels/src/components/channel_name_form_field/channel_name_form_field.test.tsx delete mode 100644 webapp/channels/src/components/create_team/components/__snapshots__/display_name.test.tsx.snap create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/__snapshots__/create_team_form.test.tsx.snap create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/create_team_form.test.tsx create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/create_team_form.tsx create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/display_name_step.test.tsx create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/display_name_step.tsx rename webapp/channels/src/components/create_team/components/{team_url => create_team_form}/index.ts (81%) create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/team_url_step.test.tsx create mode 100644 webapp/channels/src/components/create_team/components/create_team_form/team_url_step.tsx delete mode 100644 webapp/channels/src/components/create_team/components/display_name.test.tsx delete mode 100644 webapp/channels/src/components/create_team/components/display_name.tsx delete mode 100644 webapp/channels/src/components/create_team/components/team_url/__snapshots__/team_url.test.tsx.snap delete mode 100644 webapp/channels/src/components/create_team/components/team_url/team_url.test.tsx delete mode 100644 webapp/channels/src/components/create_team/components/team_url/team_url.tsx create mode 100644 webapp/channels/src/selectors/config.ts diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 7399d43df2f..329e8f18a52 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -384,6 +384,7 @@ const defaultServerConfig: AdminConfig = { PrivacySettings: { ShowEmailAddress: true, ShowFullName: true, + UseAnonymousURLs: false, }, SupportSettings: { TermsOfServiceLink: 'https://mattermost.com/pl/terms-of-use/', diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/channel_settings_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/channel_settings_modal.ts index 8372bd39122..c955a857c66 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/channel_settings_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/channel_settings_modal.ts @@ -10,6 +10,7 @@ export default class ChannelSettingsModal { readonly container: Locator; readonly closeButton; + readonly saveButton; readonly infoTab; readonly configurationTab; @@ -21,6 +22,7 @@ export default class ChannelSettingsModal { this.container = container; this.closeButton = container.getByRole('button', {name: 'Close'}); + this.saveButton = container.getByTestId('SaveChangesPanel__save-btn'); this.infoTab = container.getByRole('tab', {name: 'info'}); this.configurationTab = container.getByRole('tab', {name: 'configuration'}); @@ -45,6 +47,11 @@ export default class ChannelSettingsModal { await expect(this.container).not.toBeVisible({timeout: 10000}); } + async save() { + await expect(this.saveButton).toBeVisible(); + await this.saveButton.click(); + } + async openInfoTab(): Promise { await expect(this.infoTab).toBeVisible(); await this.infoTab.click(); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/info_settings.ts b/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/info_settings.ts index 97b957a4e6e..cc7b9e0a45b 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/info_settings.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/channel_settings/info_settings.ts @@ -5,12 +5,20 @@ import {Locator, expect} from '@playwright/test'; export default class InfoSettings { readonly container: Locator; + readonly nameInput: Locator; constructor(container: Locator) { this.container = container; + this.nameInput = container.locator('#input_channel-settings-name'); } async toBeVisible() { await expect(this.container).toBeVisible(); } + + async updateName(name: string) { + await expect(this.nameInput).toBeVisible(); + await this.nameInput.clear(); + await this.nameInput.fill(name); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/create_team_form.ts b/e2e-tests/playwright/lib/src/ui/components/channels/create_team_form.ts new file mode 100644 index 00000000000..16f457ceeda --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/create_team_form.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +export default class CreateTeamForm { + readonly container: Locator; + + // Display name step + readonly teamNameInput: Locator; + readonly teamNameSubmitButton: Locator; + readonly teamNameNextButton: Locator; + readonly teamNameError: Locator; + + // Team URL step + readonly teamURLInput: Locator; + readonly teamURLSubmitButton: Locator; + readonly teamURLFinishButton: Locator; + readonly teamURLError: Locator; + readonly backLink: Locator; + + constructor(container: Locator) { + this.container = container; + + this.teamNameInput = container.locator('#teamNameInput'); + this.teamNameSubmitButton = container.locator('#teamNameNextButton'); + this.teamNameNextButton = container.locator('#teamNameNextButton'); + this.teamNameError = container.locator('#teamNameInputError'); + + this.teamURLInput = container.locator('#teamURLInput'); + this.teamURLSubmitButton = container.locator('#teamURLFinishButton'); + this.teamURLFinishButton = container.locator('#teamURLFinishButton'); + this.teamURLError = container.locator('#teamURLInputError'); + this.backLink = container.getByText('Back to previous step'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async fillTeamName(name: string) { + await this.teamNameInput.fill(name); + } + + async submitDisplayName() { + await this.teamNameSubmitButton.click(); + } + + async fillTeamURL(url: string) { + await this.teamURLInput.fill(url); + } + + async submitTeamURL() { + await this.teamURLSubmitButton.click(); + } + + async goBack() { + await this.backLink.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/find_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/find_channels_modal.ts index 41b415cde26..e3cfb764514 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/find_channels_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/find_channels_modal.ts @@ -18,4 +18,12 @@ export default class FindChannelsModal { async toBeVisible() { await expect(this.container).toBeVisible(); } + + getResult(channelName: string) { + return this.container.getByTestId(channelName); + } + + async selectChannel(channelName: string) { + await this.getResult(channelName).click(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/header.ts b/e2e-tests/playwright/lib/src/ui/components/channels/header.ts index 0ffdb5434fe..12d75c78bee 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/header.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/header.ts @@ -6,20 +6,33 @@ import {Locator, expect} from '@playwright/test'; export default class ChannelsHeader { readonly container: Locator; + readonly title: Locator; readonly channelMenuDropdown; + readonly callButton: Locator; constructor(container: Locator) { this.container = container; + this.title = container.locator('#channelHeaderTitle'); this.channelMenuDropdown = container.locator('[aria-controls="channelHeaderDropdownMenu"]'); + this.callButton = container.getByRole('button', {name: /call/i}).first(); } async toBeVisible() { await expect(this.container).toBeVisible(); } + async toHaveTitle(title: string) { + await expect(this.title).toContainText(title); + } + async openChannelMenu() { await this.channelMenuDropdown.isVisible(); await this.channelMenuDropdown.click(); } + + async openCalls() { + await expect(this.callButton).toBeVisible(); + await this.callButton.click(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/new_channel_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/new_channel_modal.ts new file mode 100644 index 00000000000..53272fe1bd3 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/new_channel_modal.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +export default class NewChannelModal { + readonly container: Locator; + + readonly displayNameInput: Locator; + readonly urlSection: Locator; + readonly purposeInput: Locator; + readonly publicTypeButton: Locator; + readonly privateTypeButton: Locator; + readonly createButton: Locator; + readonly cancelButton: Locator; + + constructor(container: Locator) { + this.container = container; + + this.displayNameInput = container.locator('[name="new-channel-modal-name"]'); + this.urlSection = container.locator('.new-channel-modal__url'); + this.purposeInput = container.locator('#new-channel-modal-purpose'); + this.publicTypeButton = container.locator('#public-private-selector-button-O'); + this.privateTypeButton = container.locator('#public-private-selector-button-P'); + this.createButton = container.getByRole('button', {name: 'Create channel'}); + this.cancelButton = container.getByRole('button', {name: 'Cancel'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async fillDisplayName(name: string) { + await this.displayNameInput.fill(name); + await this.displayNameInput.press('Tab'); + } + + async create() { + await this.createButton.click(); + } + + async cancel() { + await this.cancelButton.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index 2a0dd64457d..b45e7bdce0e 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -9,6 +9,7 @@ import UserAccountMenu from './user_account_menu'; // Channels Components import ChannelsAppBar from './channels/app_bar'; import ChannelsCenterView from './channels/center_view'; +import CreateTeamForm from './channels/create_team_form'; import ChannelsHeader from './channels/header'; import ChannelsPost from './channels/post'; import ChannelsPostCreate from './channels/post_create'; @@ -22,6 +23,7 @@ import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal'; import DraftPost from './channels/draft_post'; import EmojiGifPicker from './channels/emoji_gif_picker'; import FindChannelsModal from './channels/find_channels_modal'; +import NewChannelModal from './channels/new_channel_modal'; import FlagPostConfirmationDialog from './channels/flag_post_confirmation_dialog'; import GenericConfirmModal from './channels/generic_confirm_modal'; import InvitePeopleModal from './channels/invite_people_modal'; @@ -51,6 +53,7 @@ import UserDetail from './system_console/sections/user_management/user_detail'; import EditionAndLicense from './system_console/sections/about/edition_and_license'; import MobileSecurity from './system_console/sections/environment/mobile_security'; import Notifications from './system_console/sections/site_configuration/notifications'; +import UsersAndTeams from './system_console/sections/site_configuration/users_and_teams'; import SystemConsoleFeatureDiscovery from './system_console/sections/system_users/feature_discovery'; import SystemConsoleHeader from './system_console/header'; import SystemConsoleNavbar from './system_console/navbar'; @@ -69,6 +72,7 @@ const components = { // Channels ChannelsAppBar, ChannelsCenterView, + CreateTeamForm, ChannelsHeader, ChannelsPost, ChannelsPostCreate, @@ -83,6 +87,7 @@ const components = { EmojiGifPicker, FindChannelsModal, FlagPostConfirmationDialog, + NewChannelModal, GenericConfirmModal, InvitePeopleModal, MembersInvitedModal, @@ -113,6 +118,7 @@ const components = { MobileSecurity, Notifications, RadioSetting, + UsersAndTeams, SystemConsoleFeatureDiscovery, SystemConsoleHeader, SystemConsoleNavbar, @@ -136,6 +142,7 @@ export { // Channels Page ChannelsAppBar, ChannelsCenterView, + CreateTeamForm, ChannelsHeader, ChannelsPost, ChannelsPostCreate, @@ -150,6 +157,7 @@ export { EmojiGifPicker, FindChannelsModal, FlagPostConfirmationDialog, + NewChannelModal, GenericConfirmModal, InvitePeopleModal, MembersInvitedModal, @@ -180,6 +188,7 @@ export { MobileSecurity, Notifications, RadioSetting, + UsersAndTeams, SystemConsoleFeatureDiscovery, SystemConsoleHeader, SystemConsoleNavbar, diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/site_configuration/users_and_teams.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/site_configuration/users_and_teams.ts new file mode 100644 index 00000000000..259a542f831 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/site_configuration/users_and_teams.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +import {RadioSetting} from '../../base_components'; + +/** + * System Console -> Site Configuration -> Users and Teams + */ +export default class UsersAndTeams { + readonly container: Locator; + + readonly header: Locator; + readonly useAnonymousURLs: RadioSetting; + readonly saveButton: Locator; + + constructor(container: Locator) { + this.container = container; + + this.header = container.getByText('Users and Teams', {exact: true}); + this.useAnonymousURLs = new RadioSetting( + container.getByRole('group', {name: /Use anonymous channel and team URLs/i}), + ); + this.saveButton = container.getByRole('button', {name: 'Save'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + await expect(this.header).toBeVisible(); + } + + async save() { + await this.saveButton.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 90b07c0545a..3f5f6ba8dfc 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -7,6 +7,8 @@ import {waitUntil} from 'async-wait-until'; import { ChannelsPost, ChannelSettingsModal, + CreateTeamForm, + NewChannelModal, SettingsModal, TeamSettingsModal, components, @@ -30,8 +32,10 @@ export default class ChannelsPage { readonly messagePriority; readonly channelSettingsModal; + readonly createTeamForm; readonly deletePostModal; readonly findChannelsModal; + readonly newChannelModal; public invitePeopleModal: InvitePeopleModal | undefined; public membersInvitedModal: MembersInvitedModal | undefined; readonly profileModal; @@ -39,6 +43,7 @@ export default class ChannelsPage { readonly teamSettingsModal; readonly scheduledDraftModal; readonly scheduleMessageModal; + readonly archivedChannelMessage; readonly postContainer; readonly postDotMenu; @@ -64,8 +69,10 @@ export default class ChannelsPage { // Modals this.channelSettingsModal = new ChannelSettingsModal(page.getByRole('dialog', {name: 'Channel Settings'})); + this.createTeamForm = new CreateTeamForm(page.locator('.signup-team__container')); this.deletePostModal = new components.DeletePostModal(page.locator('#deletePostModal')); this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'})); + this.newChannelModal = new NewChannelModal(page.locator('#new-channel-modal')); this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'})); this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'})); this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'})); @@ -87,6 +94,7 @@ export default class ChannelsPage { // Posts this.postContainer = page.locator('div.post-message__text'); + this.archivedChannelMessage = page.locator('#channelArchivedMessage'); page.locator('#channelHeaderDropdownMenu'); } @@ -135,6 +143,14 @@ export default class ChannelsPage { return channelsUrl; } + // Force the /messages route for group-message slugs that do not start with '@'. + async gotoMessage(teamName: string, channelName: string) { + const channelsUrl = `/${teamName}/messages/${channelName}`; + await this.page.goto(channelsUrl); + + return channelsUrl; + } + /** * `postMessage` posts a message in the current channel * @param message Message to post @@ -190,18 +206,34 @@ export default class ChannelsPage { return this.settingsModal; } - async newChannel(name: string, channelType: string) { - await this.page.locator('#browseOrAddChannelMenuButton').click(); + async openNewChannelModal(): Promise { + await this.sidebarLeft.browseOrCreateChannelButton.click(); await this.page.locator('#createNewChannelMenuItem').click(); - await this.page.locator('#input_new-channel-modal-name').fill(name); + await this.newChannelModal.toBeVisible(); + + return this.newChannelModal; + } + + async openCreateTeamForm(): Promise { + await this.sidebarLeft.teamMenuButton.click(); + await this.teamMenu.toBeVisible(); + await this.teamMenu.clickCreateTeam(); + await this.createTeamForm.toBeVisible(); + + return this.createTeamForm; + } + + async newChannel(name: string, channelType: string) { + const newChannelModal = await this.openNewChannelModal(); + await newChannelModal.displayNameInput.fill(name); if (channelType === 'P') { - await this.page.locator('#public-private-selector-button-P').click(); + await newChannelModal.privateTypeButton.click(); } else { - await this.page.locator('#public-private-selector-button-O').click(); + await newChannelModal.publicTypeButton.click(); } - await this.page.getByText('Create channel').click(); + await newChannelModal.create(); } async openUserAccountMenu() { diff --git a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts index a846e6f85d9..ee10cd41532 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts @@ -14,6 +14,7 @@ import PermissionsSystemScheme from '@/ui/components/system_console/sections/use import MobileSecurity from '@/ui/components/system_console/sections/environment/mobile_security'; import Localization from '@/ui/components/system_console/sections/site_configuration/localization'; import Notifications from '@/ui/components/system_console/sections/site_configuration/notifications'; +import UsersAndTeams from '@/ui/components/system_console/sections/site_configuration/users_and_teams'; import FeatureDiscovery from '@/ui/components/system_console/sections/system_users/feature_discovery'; export default class SystemConsolePage { @@ -41,6 +42,7 @@ export default class SystemConsolePage { // Site Configuration readonly localization: Localization; readonly notifications: Notifications; + readonly usersAndTeams: UsersAndTeams; // Feature Discovery (license-gated features) readonly featureDiscovery: FeatureDiscovery; @@ -72,6 +74,7 @@ export default class SystemConsolePage { // Site Configuration this.localization = new Localization(adminConsoleWrapper); this.notifications = new Notifications(adminConsoleWrapper); + this.usersAndTeams = new UsersAndTeams(adminConsoleWrapper); // Feature Discovery this.featureDiscovery = new FeatureDiscovery(adminConsoleWrapper); diff --git a/e2e-tests/playwright/specs/functional/channels/anonymous_urls/anonymous_urls.spec.ts b/e2e-tests/playwright/specs/functional/channels/anonymous_urls/anonymous_urls.spec.ts new file mode 100644 index 00000000000..0efe81ed908 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/anonymous_urls/anonymous_urls.spec.ts @@ -0,0 +1,900 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +const OBFUSCATED_SLUG_RE = /^[a-z0-9]{26}$/; + +async function skipIfNoAdvancedLicense(adminClient: any) { + const license = await adminClient.getClientLicenseOld(); + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); +} + +async function setAnonymousUrls(adminClient: any, enabled: boolean) { + await adminClient.patchConfig({ + PrivacySettings: { + UseAnonymousURLs: enabled, + }, + }); +} + +function expectObfuscatedSlug(slug: string) { + expect(slug).toMatch(OBFUSCATED_SLUG_RE); +} + +function expectReadableSlug(slug: string, expectedSlug?: string) { + if (expectedSlug) { + expect(slug).toBe(expectedSlug); + } + + expect(slug).not.toMatch(OBFUSCATED_SLUG_RE); +} + +async function createChannelFromUI(channelsPage: any, displayName: string) { + const newChannelModal = await channelsPage.openNewChannelModal(); + await newChannelModal.fillDisplayName(displayName); + await newChannelModal.create(); + await channelsPage.toBeVisible(); +} + +async function createTeamFromUI(channelsPage: any, displayName: string) { + const createTeamForm = await channelsPage.openCreateTeamForm(); + await createTeamForm.fillTeamName(displayName); + await createTeamForm.submitDisplayName(); + await channelsPage.toBeVisible(); +} + +async function getChannelByDisplayName(adminClient: any, teamId: string, displayName: string) { + const channels = await adminClient.getChannels(teamId); + const channel = channels.find((candidate: any) => candidate.display_name === displayName); + + expect(channel).toBeDefined(); + + return channel!; +} + +async function getTeamByDisplayName(adminClient: any, displayName: string) { + const teams = await adminClient.getMyTeams(); + const team = teams.find((candidate: any) => candidate.display_name === displayName); + + expect(team).toBeDefined(); + + return team!; +} + +async function createAnonymousUrlChannel( + channelsPage: any, + adminClient: any, + teamName: string, + teamId: string, + displayName: string, +) { + await createChannelFromUI(channelsPage, displayName); + await channelsPage.centerView.header.toHaveTitle(displayName); + + const channel = await getChannelByDisplayName(adminClient, teamId, displayName); + expectObfuscatedSlug(channel.name); + await expect(channelsPage.page).toHaveURL(new RegExp(`/${teamName}/channels/${channel.name}$`)); + + return channel; +} + +test.describe('Anonymous URLs', () => { + /** + * @objective Verify that the anonymous URLs setting can be toggled on from System Console and persists after navigation + * + * @precondition + * Server must have an Enterprise Advanced license + */ + test( + 'enables anonymous URLs setting from System Console and verifies it persists', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and check license + const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false}); + const license = await adminClient.getClientLicenseOld(); + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit System Console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Navigate to Users and Teams + await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click(); + await systemConsolePage.usersAndTeams.toBeVisible(); + + // * Verify the anonymous URLs radio group is visible + await systemConsolePage.usersAndTeams.useAnonymousURLs.toBeVisible(); + + // * Verify the setting is initially false + await systemConsolePage.usersAndTeams.useAnonymousURLs.toBeFalse(); + + // # Enable anonymous URLs by clicking the True radio + await systemConsolePage.usersAndTeams.useAnonymousURLs.selectTrue(); + + // * Verify it is now true + await systemConsolePage.usersAndTeams.useAnonymousURLs.toBeTrue(); + + // # Save settings + await systemConsolePage.usersAndTeams.save(); + await pw.waitUntil(async () => (await systemConsolePage.usersAndTeams.saveButton.textContent()) === 'Save'); + + // # Navigate away and come back + await systemConsolePage.sidebar.siteConfiguration.notifications.click(); + await systemConsolePage.notifications.toBeVisible(); + + await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click(); + await systemConsolePage.usersAndTeams.toBeVisible(); + + // * Verify the setting is still enabled + await systemConsolePage.usersAndTeams.useAnonymousURLs.toBeTrue(); + + // # Reset to false for cleanup + await systemConsolePage.usersAndTeams.useAnonymousURLs.selectFalse(); + await systemConsolePage.usersAndTeams.save(); + await pw.waitUntil(async () => (await systemConsolePage.usersAndTeams.saveButton.textContent()) === 'Save'); + }, + ); + + /** + * @objective Verify that the channel URL editor is hidden when creating a new channel with anonymous URLs enabled + * + * @precondition + * Server must have an Enterprise Advanced license + */ + test( + 'hides channel URL editor when creating new channel with anonymous URLs enabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and configure anonymous URLs + const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false}); + const license = await adminClient.getClientLicenseOld(); + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + await setAnonymousUrls(adminClient, true); + + // # Log in and go to channels + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Open new channel modal + await channelsPage.sidebarLeft.browseOrCreateChannelButton.click(); + await channelsPage.page.locator('#createNewChannelMenuItem').click(); + await channelsPage.newChannelModal.toBeVisible(); + + // # Fill in a channel name + await channelsPage.newChannelModal.fillDisplayName('Anonymous Test Channel'); + + // * Verify the URL editor section is not visible + await expect(channelsPage.newChannelModal.urlSection).not.toBeVisible(); + + // # Cancel modal + await channelsPage.newChannelModal.cancel(); + }, + ); + + /** + * @objective Verify that a channel created with anonymous URLs enabled has an obfuscated slug that does not match the display name + * + * @precondition + * Server must have an Enterprise Advanced license + */ + test( + 'creates channel with obfuscated URL slug when anonymous URLs enabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup + const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + const license = await adminClient.getClientLicenseOld(); + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + await setAnonymousUrls(adminClient, true); + + // # Add admin to the test team so they can create channels there + await adminClient.addToTeam(team.id, adminUser.id); + + // # Log in and go to the test team + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + // # Create a new channel via UI + const channelDisplayName = 'Obfuscated Channel ' + Date.now(); + await channelsPage.sidebarLeft.browseOrCreateChannelButton.click(); + await channelsPage.page.locator('#createNewChannelMenuItem').click(); + await channelsPage.newChannelModal.toBeVisible(); + await channelsPage.newChannelModal.fillDisplayName(channelDisplayName); + await channelsPage.newChannelModal.create(); + + // # Wait for channel to be created and navigated to + await channelsPage.toBeVisible(); + await pw.wait(pw.duration.two_sec); + + // # Fetch all channels for the team to find the newly created one by display name + const allChannels = await adminClient.getChannels(team.id); + const createdChannel = allChannels.find((ch) => ch.display_name === channelDisplayName); + + // * Verify channel was created + expect(createdChannel).toBeDefined(); + + // * Verify the slug does not match a cleaned version of the display name + const humanReadableSlug = channelDisplayName.toLowerCase().replace(/\s+/g, '-'); + expect(createdChannel!.name).not.toBe(humanReadableSlug); + + // * Verify the slug looks like a model.NewId (26 chars, alphanumeric) + expect(createdChannel!.name).toMatch(/^[a-z0-9]{26}$/); + + // * Verify display name is preserved + expect(createdChannel!.display_name).toBe(channelDisplayName); + }, + ); + + /** + * @objective Verify that the channel URL editor is visible when creating a new channel with anonymous URLs disabled (default) + */ + test( + 'shows channel URL editor when creating new channel with anonymous URLs disabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup + const {adminUser} = await pw.initSetup({withDefaultProfileImage: false}); + + // # Log in and go to channels + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Open new channel modal + await channelsPage.sidebarLeft.browseOrCreateChannelButton.click(); + await channelsPage.page.locator('#createNewChannelMenuItem').click(); + await channelsPage.newChannelModal.toBeVisible(); + + // # Type a display name to trigger URL generation + await channelsPage.newChannelModal.fillDisplayName('Test Channel URL'); + + // * Verify the URL editor section is visible + await expect(channelsPage.newChannelModal.urlSection).toBeVisible(); + + // # Cancel modal + await channelsPage.newChannelModal.cancel(); + }, + ); + + /** + * @objective Verify that the team URL step is skipped when creating a team with anonymous URLs enabled and the team is created directly after entering display name + * + * @precondition + * Server must have an Enterprise Advanced license + */ + test( + 'skips team URL step when creating team with anonymous URLs enabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup + const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false}); + const license = await adminClient.getClientLicenseOld(); + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + await setAnonymousUrls(adminClient, true); + + // # Log in and go to channels + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Open team menu and click Create a team + await channelsPage.sidebarLeft.teamMenuButton.click(); + await channelsPage.teamMenu.toBeVisible(); + await channelsPage.teamMenu.clickCreateTeam(); + + // # Wait for create team form + await channelsPage.createTeamForm.toBeVisible(); + + // * Verify the display name input is visible + await expect(channelsPage.createTeamForm.teamNameInput).toBeVisible(); + + // * Verify the submit button text says "Create" because team creation stays on a single step + await expect(channelsPage.createTeamForm.teamNameSubmitButton).toContainText('Create'); + + // # Enter team name and submit + const teamName = 'Anonymous Team ' + Date.now(); + await channelsPage.createTeamForm.fillTeamName(teamName); + await channelsPage.createTeamForm.submitDisplayName(); + + // * Verify the team is created and user is redirected (no URL step shown) + await channelsPage.toBeVisible(); + + // # Verify the team has an obfuscated slug + const teams = await adminClient.getMyTeams(); + const createdTeam = teams.find((t) => t.display_name === teamName); + expect(createdTeam).toBeDefined(); + expect(createdTeam!.name).toMatch(/^[a-z0-9]{26}$/); + }, + ); + + /** + * @objective Verify that the team URL step is shown when creating a team with anonymous URLs disabled (default) + */ + test( + 'shows team URL step when creating team with anonymous URLs disabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup + const {adminUser} = await pw.initSetup({withDefaultProfileImage: false}); + + // # Log in and go to channels + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Open team menu and click Create a team + await channelsPage.sidebarLeft.teamMenuButton.click(); + await channelsPage.teamMenu.toBeVisible(); + await channelsPage.teamMenu.clickCreateTeam(); + + // # Wait for create team form + await channelsPage.createTeamForm.toBeVisible(); + + // * Verify the submit button says "Next" on the first step because the team URL step follows + await expect(channelsPage.createTeamForm.teamNameSubmitButton).toContainText('Next'); + + // # Enter team name and click Next + await channelsPage.createTeamForm.fillTeamName('Test Team URL Step'); + await channelsPage.createTeamForm.submitDisplayName(); + + // * Verify the team URL step is now visible + await expect(channelsPage.createTeamForm.teamURLInput).toBeVisible(); + await expect(channelsPage.createTeamForm.teamURLSubmitButton).toBeVisible(); + await expect(channelsPage.createTeamForm.teamURLSubmitButton).toContainText('Finish'); + }, + ); + + /** + * @objective Verify that an archived channel created with anonymous URLs keeps its obfuscated route and becomes usable again after unarchiving. + */ + test( + 'preserves archived anonymous channel routes and restores channel access after unarchive', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + await adminClient.addToTeam(team.id, adminUser.id); + + // # Log in as admin and create a channel with an obfuscated slug + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + const channelDisplayName = `Archived Anonymous ${await pw.random.id()}`; + await createChannelFromUI(channelsPage, channelDisplayName); + + const createdChannel = await getChannelByDisplayName(adminClient, team.id, channelDisplayName); + expectObfuscatedSlug(createdChannel.name); + + // # Archive the channel and preserve the anonymous URL slug + await adminClient.deleteChannel(createdChannel.id); + const archivedChannel = await adminClient.getChannel(createdChannel.id); + + // * Verify archiving does not rotate the anonymous URL route slug + expect(archivedChannel.name).toBe(createdChannel.name); + + // # Restore the archived channel and verify the anonymous URL slug is preserved + const restoredChannel = await adminClient.unarchiveChannel(createdChannel.id); + expect(restoredChannel.name).toBe(createdChannel.name); + + // # Open the restored channel again from the sidebar + await channelsPage.page.reload(); + await channelsPage.sidebarLeft.goToItem(createdChannel.name); + + // * Verify the restored channel still uses the original anonymous URL route + await channelsPage.centerView.header.toHaveTitle(channelDisplayName); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${createdChannel.name}`); + }, + ); + + /** + * @objective Verify that enabling anonymous URLs does not rewrite existing readable slugs and only affects channels and teams created afterward. + */ + test( + 'keeps existing readable routes unchanged and obfuscates only newly created channels and teams', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup with anonymous URLs disabled by default + const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await adminClient.addToTeam(team.id, adminUser.id); + + // # Create a legacy channel and team before enabling anonymous URLs + const legacyChannelSlug = `legacy-channel-${await pw.random.id()}`; + const legacyChannelDisplayName = `Legacy Channel ${await pw.random.id()}`; + const legacyChannel = await adminClient.createChannel({ + team_id: team.id, + name: legacyChannelSlug, + display_name: legacyChannelDisplayName, + type: 'O', + }); + + const legacyTeamSlug = `legacy-team-${await pw.random.id()}`; + const legacyTeamDisplayName = `Legacy Team ${await pw.random.id()}`; + const legacyTeam = await adminClient.createTeam({ + name: legacyTeamSlug, + display_name: legacyTeamDisplayName, + type: 'O', + } as any); + + expectReadableSlug(legacyChannel.name, legacyChannelSlug); + expectReadableSlug(legacyTeam.name, legacyTeamSlug); + + // # Enable anonymous URLs after the legacy channel and team already exist + await setAnonymousUrls(adminClient, true); + + const legacyChannelAfterToggle = await adminClient.getChannel(legacyChannel.id); + const legacyTeamAfterToggle = await adminClient.getTeam(legacyTeam.id); + + // * Verify the pre-existing slugs remain readable and unchanged + expectReadableSlug(legacyChannelAfterToggle.name, legacyChannelSlug); + expectReadableSlug(legacyTeamAfterToggle.name, legacyTeamSlug); + + // # Log in and verify the original readable channel route still works + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, legacyChannelSlug); + await channelsPage.toBeVisible(); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${legacyChannelSlug}`); + + // # Create a new channel after the anonymous URL toggle + const anonymousChannelDisplayName = `Anonymous Channel ${await pw.random.id()}`; + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + await createChannelFromUI(channelsPage, anonymousChannelDisplayName); + + const anonymousChannel = await getChannelByDisplayName(adminClient, team.id, anonymousChannelDisplayName); + + // * Verify only the new channel receives an obfuscated slug + expectObfuscatedSlug(anonymousChannel.name); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${anonymousChannel.name}`); + + // # Create a new team after the anonymous URL toggle + const anonymousTeamDisplayName = `Anonymous Team ${await pw.random.id()}`; + await createTeamFromUI(channelsPage, anonymousTeamDisplayName); + + const anonymousTeam = await getTeamByDisplayName(adminClient, anonymousTeamDisplayName); + + // * Verify only the new team receives an obfuscated slug + expectObfuscatedSlug(anonymousTeam.name); + await expect(channelsPage.page).toHaveURL(new RegExp(`/${anonymousTeam.name}/`)); + }, + ); + + /** + * @objective Verify that direct and group messages continue using message routes and are excluded from anonymous URL slug obfuscation. + */ + test( + 'keeps direct and group message routes readable when anonymous URLs are enabled', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup, create message participants, and enable anonymous URLs + const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + await adminClient.addToTeam(team.id, adminUser.id); + + const secondUser = await pw.createNewUserProfile(adminClient, {prefix: 'anonymousurlsdm'}); + const thirdUser = await pw.createNewUserProfile(adminClient, {prefix: 'anonymousurlsgm'}); + await adminClient.addToTeam(team.id, secondUser.id); + await adminClient.addToTeam(team.id, thirdUser.id); + + const dmChannel = await adminClient.createDirectChannel([adminUser.id, secondUser.id]); + const gmChannel = await adminClient.createGroupChannel([adminUser.id, secondUser.id, thirdUser.id]); + + const dmMessage = `Anonymous URL DM ${await pw.random.id()}`; + const gmMessage = `Anonymous URL GM ${await pw.random.id()}`; + await adminClient.createPost({channel_id: dmChannel.id, message: dmMessage}); + await adminClient.createPost({channel_id: gmChannel.id, message: gmMessage}); + + // * Verify DM and GM channel identifiers are not replaced with obfuscated slugs + expect(dmChannel.type).toBe('D'); + expect(gmChannel.type).toBe('G'); + expectReadableSlug(dmChannel.name); + expectReadableSlug(gmChannel.name); + expect(dmChannel.name).toContain(adminUser.id); + expect(dmChannel.name).toContain(secondUser.id); + + // # Log in as admin and open the DM route + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, `@${secondUser.username}`); + await channelsPage.toBeVisible(); + + // * Verify the DM still uses the standard message route + await expect(channelsPage.page).toHaveURL(`/${team.name}/messages/@${secondUser.username}`); + await channelsPage.centerView.waitUntilLastPostContains(dmMessage); + + // # Open the GM route + await channelsPage.gotoMessage(team.name, gmChannel.name); + await channelsPage.toBeVisible(); + + // * Verify the GM still uses the standard message route + await expect(channelsPage.page).toHaveURL(`/${team.name}/messages/${gmChannel.name}`); + await channelsPage.centerView.waitUntilLastPostContains(gmMessage); + }, + ); + + /** + * @objective Verify that renaming an anonymous URL channel changes the display name without rewriting its obfuscated channel slug. + */ + test( + 'renames an anonymous URL channel without changing its obfuscated route', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and create an anonymous URL channel + const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + await adminClient.addToTeam(team.id, adminUser.id); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + const originalDisplayName = `Original Channel ${await pw.random.id()}`; + await createChannelFromUI(channelsPage, originalDisplayName); + + const createdChannel = await getChannelByDisplayName(adminClient, team.id, originalDisplayName); + const originalSlug = createdChannel.name; + expectObfuscatedSlug(originalSlug); + + // # Rename the channel from channel settings + const renamedDisplayName = `Renamed Channel ${await pw.random.id()}`; + const channelSettingsModal = await channelsPage.openChannelSettings(); + const infoTab = await channelSettingsModal.openInfoTab(); + await infoTab.updateName(renamedDisplayName); + await channelSettingsModal.save(); + + await pw.waitUntil( + async () => (await adminClient.getChannel(createdChannel.id)).display_name === renamedDisplayName, + ); + await channelSettingsModal.close(); + + const renamedChannel = await adminClient.getChannel(createdChannel.id); + + // * Verify the channel name changes without rotating the anonymous URL slug + expect(renamedChannel.display_name).toBe(renamedDisplayName); + expect(renamedChannel.name).toBe(originalSlug); + expectObfuscatedSlug(renamedChannel.name); + + // # Reopen the channel using its original obfuscated route + await channelsPage.goto(team.name, originalSlug); + await channelsPage.toBeVisible(); + + // * Verify the obfuscated route still resolves to the renamed channel + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${originalSlug}`); + await channelsPage.centerView.header.toHaveTitle(renamedDisplayName); + }, + ); + + /** + * @objective Verify that renaming an anonymous URL team changes the display name without rewriting its obfuscated team slug. + */ + test( + 'renames an anonymous URL team without changing its obfuscated route', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + + // # Log in as admin and create a team with an obfuscated slug + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + const originalTeamDisplayName = `Original Team ${await pw.random.id()}`; + await createTeamFromUI(channelsPage, originalTeamDisplayName); + + const createdTeam = await getTeamByDisplayName(adminClient, originalTeamDisplayName); + const originalTeamSlug = createdTeam.name; + expectObfuscatedSlug(originalTeamSlug); + + // # Rename the team from team settings + const renamedTeamDisplayName = `Renamed Team ${await pw.random.id()}`; + const teamSettingsModal = await channelsPage.openTeamSettings(); + const infoTab = await teamSettingsModal.openInfoTab(); + await infoTab.updateName(renamedTeamDisplayName); + await teamSettingsModal.save(); + + await pw.waitUntil( + async () => (await adminClient.getTeam(createdTeam.id)).display_name === renamedTeamDisplayName, + ); + await teamSettingsModal.close(); + + const renamedTeam = await adminClient.getTeam(createdTeam.id); + + // * Verify the team name changes without rotating the anonymous URL slug + expect(renamedTeam.display_name).toBe(renamedTeamDisplayName); + expect(renamedTeam.name).toBe(originalTeamSlug); + expectObfuscatedSlug(renamedTeam.name); + + // # Reopen the team using its original obfuscated route + await channelsPage.goto(originalTeamSlug); + await channelsPage.toBeVisible(); + + // * Verify the obfuscated team route still resolves to the renamed team + await expect(channelsPage.page).toHaveURL(new RegExp(`/${originalTeamSlug}/`)); + + const reopenedTeamSettings = await channelsPage.openTeamSettings(); + await expect(reopenedTeamSettings.infoSettings.nameInput).toHaveValue(renamedTeamDisplayName); + await reopenedTeamSettings.close(); + }, + ); + + /** + * @objective Verify that post permalinks created in anonymous URL channels continue to resolve after the feature is turned off. + */ + test( + 'opens anonymous channel permalinks before and after disabling anonymous URLs', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + + // # Log in and create an anonymous URL channel + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + const displayName = `Permalink Channel ${await pw.random.id()}`; + const channel = await createAnonymousUrlChannel(channelsPage, adminClient, team.name, team.id, displayName); + + // # Publish a post that will be opened via permalink + const message = `Anonymous permalink message ${await pw.random.id()}`; + await channelsPage.postMessage(message); + + const lastPost = await channelsPage.getLastPost(); + const postId = await lastPost.getId(); + const permalink = `/${team.name}/pl/${postId}`; + + // # Open the permalink while anonymous URLs are enabled + await channelsPage.page.goto(permalink); + + // * Verify the permalink resolves to the channel's obfuscated route + await channelsPage.centerView.header.toHaveTitle(displayName); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${channel.name}`); + await channelsPage.centerView.waitUntilPostWithIdContains(postId, message); + + // # Disable anonymous URLs and reopen the same permalink + await setAnonymousUrls(adminClient, false); + await channelsPage.page.goto(permalink); + + // * Verify the permalink still resolves to the existing obfuscated route + await channelsPage.centerView.header.toHaveTitle(displayName); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${channel.name}`); + await channelsPage.centerView.waitUntilPostWithIdContains(postId, message); + }, + ); + + /** + * @objective Verify that channel search finds anonymous URL channels by display name and navigates to their obfuscated routes. + */ + test('channel search finds channels with obfuscated URLs', {tag: '@anonymous_urls'}, async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + + // # Log in and create anonymous URL channels + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + const createdChannels = []; + for (let i = 1; i <= 3; i++) { + const displayName = `Search Test Channel ${i} ${await pw.random.id()}`; + const channel = await createAnonymousUrlChannel(channelsPage, adminClient, team.name, team.id, displayName); + createdChannels.push({channel, displayName}); + } + + const targetChannel = createdChannels[0]; + + // # Open Find Channels and search by display name + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + await channelsPage.sidebarLeft.findChannelButton.click(); + await channelsPage.findChannelsModal.toBeVisible(); + await channelsPage.findChannelsModal.input.fill(targetChannel.displayName.substring(0, 15)); + + // * Verify the anonymous URL channel appears in results + const result = channelsPage.findChannelsModal.getResult(targetChannel.channel.name); + await expect(result).toBeVisible(); + await expect(result).toContainText(targetChannel.displayName); + + // # Select the matching anonymous URL channel + await channelsPage.findChannelsModal.selectChannel(targetChannel.channel.name); + + // * Verify navigation lands on the obfuscated route + await channelsPage.centerView.header.toHaveTitle(targetChannel.displayName); + await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${targetChannel.channel.name}`); + }); + + /** + * @objective Verify that post search results navigate back to the correct anonymous URL channel route. + */ + test('navigates post search results back to anonymous URL channels', {tag: '@anonymous_urls'}, async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + + // # Log in and create an anonymous URL channel with a searchable post + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + const displayName = `Search Channel ${await pw.random.id()}`; + const channel = await createAnonymousUrlChannel(channelsPage, adminClient, team.name, team.id, displayName); + const message = `AnonymousSearchableMessage${await pw.random.id()}`; + await channelsPage.postMessage(message); + + const lastPost = await channelsPage.getLastPost(); + const postId = await lastPost.getId(); + + // # Open message search from another channel + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.searchInput.fill(message); + await channelsPage.searchBox.searchInput.press('Enter'); + + const searchItem = channelsPage.page.getByTestId('search-item-container').first(); + + // * Verify the post appears in results + await expect(searchItem).toContainText(message, {timeout: 15000}); + + // # Jump from the search result back to the anonymous URL channel + await searchItem.getByRole('link', {name: 'Jump'}).click(); + + // * Verify navigation returns to the anonymous URL route and highlights the expected post + await channelsPage.centerView.header.toHaveTitle(displayName); + await expect(channelsPage.page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}`)); + await channelsPage.centerView.waitUntilPostWithIdContains(postId, message); + }); + + /** + * @objective Verify that anonymous URLs preserve edge-case channel display names while keeping channels discoverable and fully usable. + */ + test( + 'creates anonymous URL channels from unicode and emoji display names and keeps them discoverable', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and enable anonymous URLs + const {user, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + await setAnonymousUrls(adminClient, true); + + // # Log in and open the test team + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Create channels with edge-case display names + const channelTemplates = ['Design review 🚀', 'Roadmap 中文', 'Support & Ops عربى']; + const createdChannels = []; + + for (const template of channelTemplates) { + const displayName = `${template} ${(await pw.random.id()).slice(0, 6)}`; + const channel = await createAnonymousUrlChannel( + channelsPage, + adminClient, + team.name, + team.id, + displayName, + ); + + createdChannels.push({channel, displayName}); + + // # Post in the new anonymous URL channel + const message = `anonymous-url edge case ${displayName}`; + await channelsPage.postMessage(message); + + // * Verify the channel keeps the exact display name and remains usable + const lastPost = await channelsPage.getLastPost(); + await expect(lastPost.body).toContainText(message); + await channelsPage.centerView.header.toHaveTitle(displayName); + await expect(channelsPage.page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`)); + } + + const searchableChannel = createdChannels[1]; + + // # Reopen the channel from Find Channels using its display name + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + await channelsPage.sidebarLeft.findChannelButton.click(); + await channelsPage.findChannelsModal.toBeVisible(); + await channelsPage.findChannelsModal.input.fill(searchableChannel.displayName); + + // * Verify search results use the display name and navigate back to the obfuscated slug + await expect(channelsPage.findChannelsModal.getResult(searchableChannel.channel.name)).toBeVisible(); + await channelsPage.findChannelsModal.selectChannel(searchableChannel.channel.name); + await channelsPage.centerView.header.toHaveTitle(searchableChannel.displayName); + await expect(channelsPage.page).toHaveURL( + new RegExp(`/${team.name}/channels/${searchableChannel.channel.name}$`), + ); + }, + ); + + /** + * @objective Verify that anonymous URL channels with special-character names still expose the Calls entry point after direct navigation. + * + * @precondition + * Calls plugin must be installed in the test environment. + */ + test( + 'shows calls entry point in anonymous URL channels with special-character names and preserves the obfuscated route', + {tag: '@anonymous_urls'}, + async ({pw}) => { + // # Initialize setup and validate Calls prerequisites + const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false}); + await skipIfNoAdvancedLicense(adminClient); + + const pluginStatuses = await adminClient.getPluginStatuses(); + test.skip( + !pluginStatuses.some((plugin: {plugin_id: string}) => plugin.plugin_id === 'com.mattermost.calls'), + 'Skipping test - Calls plugin is not installed', + ); + + await pw.ensurePluginsLoaded(['com.mattermost.calls']); + await pw.shouldHaveCallsEnabled(); + await setAnonymousUrls(adminClient, true); + await adminClient.addToTeam(team.id, adminUser.id); + + // # Create an anonymous URL channel with a special-character display name + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + const displayName = `Calls & Planning 🚀 ${(await pw.random.id()).slice(0, 6)}`; + const channel = await createAnonymousUrlChannel(channelsPage, adminClient, team.name, team.id, displayName); + await adminClient.addToChannel(adminUser.id, channel.id); + + // * Verify the current anonymous URL channel shows the Calls entry point + await channelsPage.centerView.header.toHaveTitle(displayName); + await expect(channelsPage.centerView.header.callButton).toBeVisible(); + + // # Open the anonymous URL directly as another member + const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(adminUser); + await otherChannelsPage.goto(team.name, channel.name); + await otherChannelsPage.toBeVisible(); + + // * Verify direct navigation keeps the obfuscated slug and still exposes Calls + await otherChannelsPage.centerView.header.toHaveTitle(displayName); + await expect(otherChannelsPage.page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`)); + await expect(otherChannelsPage.centerView.header.callButton).toBeVisible(); + + // # Open the Calls entry point from the anonymous URL channel + await otherChannelsPage.centerView.header.openCalls(); + + // * Verify opening Calls does not break the anonymous URL route context + await otherChannelsPage.centerView.header.toHaveTitle(displayName); + await expect(otherChannelsPage.page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}`)); + }, + ); +}); diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 2afc9012e30..bb72a50466f 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -91,6 +91,10 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParamWithErr("channel", err) return } + license := c.App.Channels().License() + if !channel.IsGroupOrDirect() && model.SafeDereference(c.App.Config().PrivacySettings.UseAnonymousURLs) && model.MinimumEnterpriseAdvancedLicense(license) { + channel.Name = model.NewId() + } if channel.TeamId == "" { c.SetInvalidParamWithDetails("team_id", i18n.T("api.channel.create_channel.missing_team_id.error")) diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 231ccb6592c..5b88e54258c 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -204,6 +204,46 @@ func TestCreateChannel(t *testing.T) { CheckBadRequestStatus(t, resp) }) + t.Run("should override channel name with server-generated ID when UseAnonymousURLs is enabled and not otherwise", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := GenerateTestChannelName() + ch := &model.Channel{DisplayName: "Anonymous URL Channel", Name: originalName, Type: model.ChannelTypeOpen, TeamId: team.Id} + createdChannel, response, err := th.SystemAdminClient.CreateChannel(context.Background(), ch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + require.NotEqual(t, originalName, createdChannel.Name, "channel name should be overridden by server") + require.True(t, model.IsValidId(createdChannel.Name)) + require.Equal(t, "Anonymous URL Channel", createdChannel.DisplayName, "display name should remain unchanged") + + // setting UseAnonymousURLs to false should preserve names + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + ch = &model.Channel{DisplayName: "Regular Channel", Name: originalName, Type: model.ChannelTypeOpen, TeamId: team.Id} + createdChannel, response, err = th.SystemAdminClient.CreateChannel(context.Background(), ch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + require.Equal(t, originalName, createdChannel.Name) + + // setting license to something other than Enterprise Advanced should also preserve team name + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + + originalName = GenerateTestChannelName() + ch = &model.Channel{DisplayName: "Regular Channel", Name: originalName, Type: model.ChannelTypeOpen, TeamId: team.Id} + createdChannel, response, err = th.SystemAdminClient.CreateChannel(context.Background(), ch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + require.Equal(t, originalName, createdChannel.Name) + }) + t.Run("Guest users", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) diff --git a/server/channels/api4/team.go b/server/channels/api4/team.go index 67b7e134a2e..4d731c7e8b8 100644 --- a/server/channels/api4/team.go +++ b/server/channels/api4/team.go @@ -83,6 +83,12 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) { } team.Email = strings.ToLower(team.Email) + license := c.App.Channels().License() + + if model.SafeDereference(c.App.Config().PrivacySettings.UseAnonymousURLs) && model.MinimumEnterpriseAdvancedLicense(license) { + team.Name = model.NewId() + } + auditRec := c.MakeAuditRecord(model.AuditEventCreateTeam, model.AuditStatusFail) defer c.LogAuditRec(auditRec) model.AddEventParameterAuditableToAuditRec(auditRec, "team", &team) diff --git a/server/channels/api4/team_test.go b/server/channels/api4/team_test.go index ddc38fd087c..54376878196 100644 --- a/server/channels/api4/team_test.go +++ b/server/channels/api4/team_test.go @@ -153,6 +153,47 @@ func TestCreateTeam(t *testing.T) { } }) + t.Run("should override team name with server-generated ID when UseAnonymousURLs is enabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + th.LoginBasic(t) + + originalName := "originalname" + team := &model.Team{Name: originalName, DisplayName: "Anonymous URL Team", Type: model.TeamOpen} + createdTeam, resp, err := th.Client.CreateTeam(context.Background(), team) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + + require.NotEqual(t, originalName, createdTeam.Name, "team name should be overridden by server") + require.True(t, model.IsValidId(createdTeam.Name)) + require.Equal(t, "Anonymous URL Team", createdTeam.DisplayName, "display name should remain unchanged") + + // setting UseAnonymousURLs to false should preserve team name + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + team = &model.Team{Name: originalName, DisplayName: "Regular URL Team", Type: model.TeamOpen} + createdTeam, resp, err = th.Client.CreateTeam(context.Background(), team) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.Equal(t, originalName, createdTeam.Name) + + // setting license to something other than Enterprise Advanced should preserve team name + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + + originalName = "original-name-2" + team = &model.Team{Name: originalName, DisplayName: "Regular URL Team", Type: model.TeamOpen} + createdTeam, resp, err = th.Client.CreateTeam(context.Background(), team) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.Equal(t, originalName, createdTeam.Name) + }) + t.Run("cloud limit reached returns 400", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index acfe3506771..82678fa00fb 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -161,6 +161,10 @@ func (api *PluginAPI) GetTelemetryId() string { } func (api *PluginAPI) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { + if model.SafeDereference(api.app.Config().PrivacySettings.UseAnonymousURLs) && model.MinimumEnterpriseAdvancedLicense(api.app.License()) { + team.Name = model.NewId() + } + return api.app.CreateTeam(api.ctx, team) } @@ -457,6 +461,11 @@ func (api *PluginAPI) GetLDAPUserAttributes(userID string, attributes []string) } func (api *PluginAPI) CreateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { + UseAnonymousURLs := model.SafeDereference(api.app.Config().PrivacySettings.UseAnonymousURLs) && model.MinimumEnterpriseAdvancedLicense(api.app.License()) + if !channel.IsGroupOrDirect() && UseAnonymousURLs { + channel.Name = model.NewId() + } + return api.app.CreateChannel(api.ctx, channel, false) } diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 612883d7f88..7a17788e9e3 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -3519,3 +3519,192 @@ func TestPluginAPICountPropertyFields(t *testing.T) { assert.Equal(t, int64(0), count) }) } + +func TestPluginAPICreateTeamAnonymousURLs(t *testing.T) { + mainHelper.Parallel(t) + + th := Setup(t) + api := th.SetupPluginAPI() + + t.Run("should override team name when UseAnonymousURLs is enabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "original-team-name" + team := &model.Team{ + DisplayName: "Anonymous URL Team", + Name: originalName, + Type: model.TeamOpen, + } + + createdTeam, appErr := api.CreateTeam(team) + require.Nil(t, appErr) + require.NotNil(t, createdTeam) + + assert.NotEqual(t, originalName, createdTeam.Name, "team name should be overridden by server") + assert.True(t, model.IsValidId(createdTeam.Name), "team name should be a valid server-generated ID") + assert.Equal(t, "Anonymous URL Team", createdTeam.DisplayName, "display name should remain unchanged") + }) + + t.Run("should preserve team name when UseAnonymousURLs is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "preserved-team-name" + team := &model.Team{ + DisplayName: "Normal Team", + Name: originalName, + Type: model.TeamOpen, + } + + createdTeam, appErr := api.CreateTeam(team) + require.Nil(t, appErr) + require.NotNil(t, createdTeam) + + assert.Equal(t, originalName, createdTeam.Name, "team name should not be overridden") + }) + + t.Run("should not override team name without Enterprise Advanced license", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "original-team-name" + team := &model.Team{ + DisplayName: "Enterprise Team", + Name: originalName, + Type: model.TeamOpen, + } + + createdTeam, appErr := api.CreateTeam(team) + require.Nil(t, appErr) + require.NotNil(t, createdTeam) + + assert.Equal(t, originalName, createdTeam.Name, "team name should not be overridden") + }) +} + +func TestPluginAPICreateChannelAnonymousURLs(t *testing.T) { + mainHelper.Parallel(t) + + th := Setup(t).InitBasic(t) + api := th.SetupPluginAPI() + + t.Run("should override open channel name when UseAnonymousURLs is enabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "original-channel-name" + channel := &model.Channel{ + DisplayName: "Anonymous URL Channel", + Name: originalName, + Type: model.ChannelTypeOpen, + TeamId: th.BasicTeam.Id, + } + + createdChannel, appErr := api.CreateChannel(channel) + require.Nil(t, appErr) + require.NotNil(t, createdChannel) + + assert.NotEqual(t, originalName, createdChannel.Name, "open channel name should be overridden") + assert.True(t, model.IsValidId(createdChannel.Name), "channel name should be a valid server-generated ID") + assert.Equal(t, "Anonymous URL Channel", createdChannel.DisplayName, "display name should remain unchanged") + }) + + t.Run("should override private channel name when UseAnonymousURLs is enabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "private-channel-name" + channel := &model.Channel{ + DisplayName: "Anonymous Private Channel", + Name: originalName, + Type: model.ChannelTypePrivate, + TeamId: th.BasicTeam.Id, + } + + createdChannel, appErr := api.CreateChannel(channel) + require.Nil(t, appErr) + require.NotNil(t, createdChannel) + + assert.NotEqual(t, originalName, createdChannel.Name, "private channel name should be overridden") + assert.True(t, model.IsValidId(createdChannel.Name), "channel name should be a valid server-generated ID") + }) + + t.Run("should preserve channel name when UseAnonymousURLs is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "preserved-channel" + channel := &model.Channel{ + DisplayName: "Normal Channel", + Name: originalName, + Type: model.ChannelTypeOpen, + TeamId: th.BasicTeam.Id, + } + + createdChannel, appErr := api.CreateChannel(channel) + require.Nil(t, appErr) + require.NotNil(t, createdChannel) + + assert.Equal(t, originalName, createdChannel.Name, "channel name should not be overridden") + }) + + t.Run("should not override channel name without Enterprise Advanced license", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + originalName := "original-channel-name" + channel := &model.Channel{ + DisplayName: "Normal Channel", + Name: originalName, + Type: model.ChannelTypeOpen, + TeamId: th.BasicTeam.Id, + } + + createdChannel, appErr := api.CreateChannel(channel) + require.Nil(t, appErr) + require.NotNil(t, createdChannel) + + assert.Equal(t, originalName, createdChannel.Name, "channel name should not be overridden") + }) +} diff --git a/server/config/client.go b/server/config/client.go index 78d8337091e..56b23868910 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -72,6 +72,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["ShowEmailAddress"] = strconv.FormatBool(*c.PrivacySettings.ShowEmailAddress) props["ShowFullName"] = strconv.FormatBool(*c.PrivacySettings.ShowFullName) + props["UseAnonymousURLs"] = strconv.FormatBool(*c.PrivacySettings.UseAnonymousURLs) props["EnableFileAttachments"] = strconv.FormatBool(*c.FileSettings.EnableFileAttachments) props["EnablePublicLink"] = strconv.FormatBool(*c.FileSettings.EnablePublicLink) diff --git a/server/config/client_test.go b/server/config/client_test.go index 31aff1f1b85..cb0b54bc4ca 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -150,6 +150,19 @@ func TestGetClientConfig(t *testing.T) { "ShowFullName": "true", }, }, + { + "enable UseAnonymousURLs prop", + &model.Config{ + PrivacySettings: model.PrivacySettings{ + UseAnonymousURLs: model.NewPointer(true), + }, + }, + "tag1", + nil, + map[string]string{ + "UseAnonymousURLs": "true", + }, + }, { "Custom groups professional license", &model.Config{}, diff --git a/server/public/model/config.go b/server/public/model/config.go index d983684c234..370e3863241 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -2204,6 +2204,7 @@ func (s *RateLimitSettings) SetDefaults() { type PrivacySettings struct { ShowEmailAddress *bool `access:"site_users_and_teams"` ShowFullName *bool `access:"site_users_and_teams"` + UseAnonymousURLs *bool `access:"site_users_and_teams"` } func (s *PrivacySettings) setDefaults() { @@ -2214,6 +2215,10 @@ func (s *PrivacySettings) setDefaults() { if s.ShowFullName == nil { s.ShowFullName = NewPointer(true) } + + if s.UseAnonymousURLs == nil { + s.UseAnonymousURLs = NewPointer(false) + } } type SupportSettings struct { diff --git a/webapp/channels/src/actions/views/drafts.test.ts b/webapp/channels/src/actions/views/drafts.test.ts index 76dc5adbe32..5ab7f806fc4 100644 --- a/webapp/channels/src/actions/views/drafts.test.ts +++ b/webapp/channels/src/actions/views/drafts.test.ts @@ -104,6 +104,9 @@ describe('draft actions', () => { AllowSyncedDrafts: 'true', }, }, + channels: { + channels: {}, + }, }, storage: { storage: { diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 55da234e18e..743eea65e53 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -2644,6 +2644,15 @@ const AdminDefinition: AdminDefinitionType = { ], isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), }, + { + type: 'bool', + key: 'PrivacySettings.UseAnonymousURLs', + label: defineMessage({id: 'admin.privacy.useAnonymousURLsTitle', defaultMessage: 'Use anonymous channel and team URLs:'}), + help_text: defineMessage({id: 'admin.privacy.useAnonymousURLsDescription', defaultMessage: 'When true, newly created channels and teams use randomized, non-descriptive identifiers in their URLs instead of human-readable name slugs. This prevents channel and team names from being exposed when team, channel, or message URLs are shared. **Note:** Enabling this setting does not change the URLs of existing teams and channels. To update existing URLs to use anonymous identifiers, use the mmctl command line tool or update them manually.'}), + help_text_markdown: true, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), + isHidden: it.not(it.minLicenseTier(LicenseSkus.EnterpriseAdvanced)), + }, { type: 'dropdown', key: 'TeamSettings.TeammateNameDisplay', diff --git a/webapp/channels/src/components/backstage/components/backstage_navbar.test.tsx b/webapp/channels/src/components/backstage/components/backstage_navbar.test.tsx new file mode 100644 index 00000000000..afff9e4f212 --- /dev/null +++ b/webapp/channels/src/components/backstage/components/backstage_navbar.test.tsx @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Team} from '@mattermost/types/teams'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import BackstageNavbar from './backstage_navbar'; + +describe('components/backstage/components/BackstageNavbar', () => { + const activeTeam = { + name: 'my-team', + display_name: 'My Team', + delete_at: 0, + } as Team; + + test('should render back link to team channel when team exists', () => { + renderWithContext( + , + ); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/my-team'); + expect(screen.getByText('Back to Mattermost')).toBeInTheDocument(); + }); + + test('should use team display_name when siteName is not provided', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Back to My Team')).toBeInTheDocument(); + }); + + test('should render generic back link when team is undefined', () => { + renderWithContext( + , + ); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/'); + expect(screen.getByText('Back')).toBeInTheDocument(); + }); + + test('should render generic back link when team is deleted', () => { + const deletedTeam = { + ...activeTeam, + delete_at: 1234567890, + } as Team; + + renderWithContext( + , + ); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/'); + expect(screen.getByText('Back')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/backstage/components/backstage_navbar.tsx b/webapp/channels/src/components/backstage/components/backstage_navbar.tsx index 349bd8ecee9..7afcea12b3a 100644 --- a/webapp/channels/src/components/backstage/components/backstage_navbar.tsx +++ b/webapp/channels/src/components/backstage/components/backstage_navbar.tsx @@ -29,7 +29,7 @@ const BackstageNavbar = ({team, siteName}: Props) => { ) : ( ({ + entities: { + general: { + config: { + UseAnonymousURLs, + }, + license: {SkuShortName: LicenseSkus.EnterpriseAdvanced}, + }, + teams: { + currentTeamId: 'team-id', + teams: { + 'team-id': { + id: 'team-id', + name: 'test-team', + display_name: 'Test Team', + }, + }, + }, + }, +}); + +describe('ChannelNameFormField - URL editor visibility', () => { + test('should show URL editor when UseAnonymousURLs is false and creating a new channel', () => { + renderWithContext( + , + makeState('false'), + ); + + expect(screen.getByTestId('urlInputLabel')).toBeVisible(); + }); + + test('should show URL editor when UseAnonymousURLs is false and editing an existing channel', () => { + renderWithContext( + , + makeState('false'), + ); + + expect(screen.getByTestId('urlInputLabel')).toBeVisible(); + }); + + test('should not show URL editor when UseAnonymousURLs is true and creating a new channel', () => { + renderWithContext( + , + makeState('true'), + ); + + expect(screen.queryByTestId('urlInputLabel')).not.toBeInTheDocument(); + }); + + test('should show URL editor when UseAnonymousURLs is true and editing an existing channel', () => { + renderWithContext( + , + makeState('true'), + ); + + expect(screen.getByTestId('urlInputLabel')).toBeVisible(); + }); +}); diff --git a/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx b/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx index 010af646d65..79abcd6d6f1 100644 --- a/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx +++ b/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx @@ -10,6 +10,8 @@ import type {Team} from '@mattermost/types/teams'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +import {isAnonymousURLEnabled} from 'selectors/config'; + import type {CustomMessageInputType} from 'components/widgets/inputs/input/input'; import Input from 'components/widgets/inputs/input/input'; import URLInput from 'components/widgets/inputs/url_input/url_input'; @@ -56,6 +58,7 @@ function validateDisplayName(intl: IntlShape, displayNameParam: string) { const ChannelNameFormField = (props: Props): JSX.Element => { const intl = useIntl(); const {formatMessage} = intl; + const useAnonymousURLs = useSelector(isAnonymousURLEnabled); // Track if the field has been interacted with const [hasInteracted, setHasInteracted] = useState(false); @@ -184,6 +187,8 @@ const ChannelNameFormField = (props: Props): JSX.Element => { } }, [props.currentUrl]); + const showURLEditor = props.isEditingExistingChannel || !useAnonymousURLs; + return ( <> { onBlur={handleOnDisplayNameBlur} disabled={props.readOnly} /> - + { + showURLEditor && + + } ); }; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx index 744f6437ac9..f2241767350 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx @@ -16,6 +16,7 @@ import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal/conv import TestHelper from 'packages/mattermost-redux/test/test_helper'; import {fireEvent, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import {LicenseSkus} from 'utils/constants'; import type {GlobalState} from 'types/store'; @@ -42,11 +43,25 @@ describe('component/ConvertGmToChannelModal', () => { entities: { teams: { teams: { - team_id_1: {id: 'team_id_1', display_name: 'Team 1', name: 'team_1'} as Team, - team_id_2: {id: 'team_id_2', display_name: 'Team 2', name: 'team_2'} as Team, + team_id_1: { + id: 'team_id_1', + display_name: 'Team 1', + name: 'team_1', + } as Team, + team_id_2: { + id: 'team_id_2', + display_name: 'Team 2', + name: 'team_2', + } as Team, }, currentTeamId: 'team_id_1', }, + general: { + config: { + UseAnonymousURLs: 'false', + }, + license: {SkuShortName: LicenseSkus.EnterpriseAdvanced}, + }, }, }; @@ -173,6 +188,47 @@ describe('component/ConvertGmToChannelModal', () => { await userEvent.click(confirmButton!); }); + test('when UseAnonymousURLs is enabled, user cannot specify a channel URL', async () => { + TestHelper.initBasic(Client4); + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, [ + {id: 'team_id_1', display_name: 'Team 1', name: 'team_1'}, + ]); + + const anonymousURLState: DeepPartial = { + ...baseState, + entities: { + ...baseState.entities, + general: { + ...baseState.entities?.general, + config: { + UseAnonymousURLs: 'true', + }, + }, + }, + }; + + renderWithContext( + , + anonymousURLState, + ); + + await waitFor( + () => + expect( + screen.queryByText( + 'Conversation history will be visible to any channel members', + ), + ).toBeInTheDocument(), + {timeout: 1500}, + ); + + expect(screen.queryByPlaceholderText('Channel name')).toBeVisible(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText(/URL:/)).not.toBeInTheDocument(); + }); + test('duplicate channel names should npt be allowed', async () => { TestHelper.initBasic(Client4); diff --git a/webapp/channels/src/components/create_team/__snapshots__/create_team.test.tsx.snap b/webapp/channels/src/components/create_team/__snapshots__/create_team.test.tsx.snap index 5cd4a8ed093..cc1dd501815 100644 --- a/webapp/channels/src/components/create_team/__snapshots__/create_team.test.tsx.snap +++ b/webapp/channels/src/components/create_team/__snapshots__/create_team.test.tsx.snap @@ -76,6 +76,7 @@ exports[`component/create_team should match snapshot default 1`] = ` class="Input_wrapper" > -
-
- - -
-
-
-
-
- -
- -
-
-
-
-
-
-
- Name your team in any language. Your team name shows in menus and headings. -
- - -
- -`; diff --git a/webapp/channels/src/components/create_team/components/create_team_form/__snapshots__/create_team_form.test.tsx.snap b/webapp/channels/src/components/create_team/components/create_team_form/__snapshots__/create_team_form.test.tsx.snap new file mode 100644 index 00000000000..71a4bcba051 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/__snapshots__/create_team_form.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CreateTeamForm - display_name step should match snapshot 1`] = ` +
+
+
+ + +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ Name your team in any language. Your team name shows in menus and headings. +
+ + +
+
+`; + +exports[`CreateTeamForm - team_url step should match snapshot 1`] = ` +
+
+
+ + +
+
+
+
+ + http://localhost:8065/ + + +
+
+
+
+

+ Choose the web address of your new team: +

+
    +
  • + Short and memorable is best +
  • +
  • + Use lowercase letters, numbers and dashes +
  • +
  • + Must start with a letter and can't end in a dash +
  • +
+
+ +
+ +
+
+
+`; diff --git a/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.test.tsx b/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.test.tsx new file mode 100644 index 00000000000..54c16909fe3 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.test.tsx @@ -0,0 +1,378 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import CreateTeamForm from 'components/create_team/components/create_team_form/create_team_form'; + +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import Constants, {LicenseSkus} from 'utils/constants'; + +jest.mock('images/logo.png', () => 'logo.png'); + +const defaultState = { + entities: { + general: { + config: { + UseAnonymousURLs: 'false', + }, + }, + }, +}; + +describe('CreateTeamForm - display_name step', () => { + let defaultProps: { + step: 'display_name'; + updateParent: jest.Mock; + state: {team: {name: string; display_name: string}; wizard: string}; + actions: {checkIfTeamExists: jest.Mock; createTeam: jest.Mock}; + history: {push: jest.Mock}; + }; + + beforeEach(() => { + jest.clearAllMocks(); + defaultProps = { + step: 'display_name' as const, + updateParent: jest.fn(), + state: { + team: {name: 'test-team', display_name: 'test-team'}, + wizard: 'display_name', + }, + actions: { + checkIfTeamExists: jest.fn().mockResolvedValue({data: false}), + createTeam: jest.fn().mockResolvedValue({data: {name: 'test-team'}}), + }, + history: {push: jest.fn()}, + }; + }); + + test('should match snapshot', () => { + const {container} = renderWithContext(, defaultState); + expect(container).toMatchSnapshot(); + }); + + test('should run updateParent function on next', async () => { + renderWithContext(, defaultState); + + await userEvent.click(screen.getByRole('button', {name: /next/i})); + + expect(defaultProps.updateParent).toHaveBeenCalled(); + }); + + test('should pass state to updateParent function', async () => { + renderWithContext(, defaultState); + + await userEvent.click(screen.getByRole('button', {name: /next/i})); + + expect(defaultProps.updateParent).toHaveBeenCalledWith(expect.objectContaining({ + wizard: 'team_url', + team: expect.objectContaining({ + display_name: 'test-team', + }), + })); + }); + + test('should pass updated team name to updateParent function', async () => { + renderWithContext(, defaultState); + const teamDisplayName = 'My Test Team'; + + const input = screen.getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, teamDisplayName); + + await userEvent.click(screen.getByRole('button', {name: /next/i})); + + expect(defaultProps.updateParent).toHaveBeenCalledWith(expect.objectContaining({ + wizard: 'team_url', + team: expect.objectContaining({ + display_name: teamDisplayName, + }), + })); + }); + + describe('with UseAnonymousURLs enabled', () => { + const anonymousURLState = { + entities: { + general: { + config: { + UseAnonymousURLs: 'true', + }, + license: {SkuShortName: LicenseSkus.EnterpriseAdvanced}, + }, + }, + }; + + test('should show Create button instead of Next', () => { + renderWithContext(, anonymousURLState); + + expect(screen.getByRole('button', {name: /create/i})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /next/i})).not.toBeInTheDocument(); + }); + + test('should create team directly without going to team_url step', async () => { + renderWithContext(, anonymousURLState); + + await userEvent.click(screen.getByRole('button', {name: /create/i})); + + await waitFor(() => { + expect(defaultProps.actions.createTeam).toHaveBeenCalledTimes(1); + expect(defaultProps.actions.createTeam).toHaveBeenCalledWith(expect.objectContaining({ + display_name: 'test-team', + type: 'O', + })); + }); + + expect(defaultProps.updateParent).not.toHaveBeenCalled(); + }); + + test('should navigate to team default channel on successful creation', async () => { + const actions = { + ...defaultProps.actions, + createTeam: jest.fn().mockResolvedValue({data: {name: 'my-new-team'}}), + }; + + renderWithContext( + , + anonymousURLState, + ); + + await userEvent.click(screen.getByRole('button', {name: /create/i})); + + await waitFor(() => { + expect(defaultProps.history.push).toHaveBeenCalledWith('/my-new-team/channels/town-square'); + }); + }); + + test('should display error when team creation fails', async () => { + const actions = { + ...defaultProps.actions, + createTeam: jest.fn().mockResolvedValue({error: {message: 'Team creation failed'}}), + }; + + renderWithContext( + , + anonymousURLState, + ); + + await userEvent.click(screen.getByRole('button', {name: /create/i})); + + await waitFor(() => { + expect(screen.getByText('Team creation failed')).toBeInTheDocument(); + }); + + expect(defaultProps.history.push).not.toHaveBeenCalled(); + }); + + test('should show loading state while creating team', async () => { + let resolveCreateTeam: (value: {data: {name: string}}) => void; + const createTeamPromise = new Promise<{data: {name: string}}>((resolve) => { + resolveCreateTeam = resolve; + }); + + const actions = { + ...defaultProps.actions, + createTeam: jest.fn().mockReturnValue(createTeamPromise), + }; + + renderWithContext( + , + anonymousURLState, + ); + + await userEvent.click(screen.getByRole('button', {name: /create/i})); + + await waitFor(() => { + expect(screen.getByText('Creating team...')).toBeInTheDocument(); + }); + + resolveCreateTeam!({data: {name: 'my-new-team'}}); + + await waitFor(() => { + expect(defaultProps.history.push).toHaveBeenCalled(); + }); + }); + + test('should not create team when display name is too short', async () => { + const props = { + ...defaultProps, + state: { + team: {name: '', display_name: ''}, + wizard: 'display_name', + }, + }; + + renderWithContext(, anonymousURLState); + + await userEvent.click(screen.getByRole('button', {name: /create/i})); + + expect(defaultProps.actions.createTeam).not.toHaveBeenCalled(); + expect(defaultProps.updateParent).not.toHaveBeenCalled(); + }); + }); +}); + +describe('CreateTeamForm - team_url step', () => { + const defaultProps = { + step: 'team_url' as const, + updateParent: jest.fn(), + state: { + team: {name: 'test-team', display_name: 'test-team'}, + wizard: 'team_url', + }, + actions: { + checkIfTeamExists: jest.fn().mockResolvedValue({data: true}), + createTeam: jest.fn().mockResolvedValue({data: {name: 'test-team'}}), + }, + history: {push: jest.fn()}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should match snapshot', () => { + const {container} = renderWithContext(, defaultState); + expect(container).toMatchSnapshot(); + }); + + test('should return to display_name page', async () => { + renderWithContext(, defaultState); + + await userEvent.click(screen.getByText('Back to previous step')); + + expect(defaultProps.updateParent).toHaveBeenCalledWith({ + ...defaultProps.state, + wizard: 'display_name', + }); + }); + + test('should show Finish button on the team_url step', () => { + renderWithContext(, defaultState); + + expect(screen.getByRole('button', {name: /finish/i})).toBeInTheDocument(); + }); + + test('should successfully submit', async () => { + const checkIfTeamExists = jest.fn(). + mockResolvedValueOnce({data: true}). + mockResolvedValue({data: false}); + + const actions = {...defaultProps.actions, checkIfTeamExists}; + const props = {...defaultProps, actions}; + + renderWithContext( + , + defaultState, + ); + + await userEvent.click(screen.getByText('Finish')); + + await waitFor(() => { + expect(screen.getByText('This URL is taken or unavailable. Please try another.')).toBeInTheDocument(); + }); + + expect(actions.checkIfTeamExists).toHaveBeenCalledTimes(1); + expect(actions.createTeam).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByText('Finish')); + + await waitFor(() => { + expect(actions.checkIfTeamExists).toHaveBeenCalledTimes(2); + expect(actions.createTeam).toHaveBeenCalledTimes(1); + expect(actions.createTeam).toHaveBeenCalledWith({display_name: 'test-team', name: 'test-team', type: 'O'}); + expect(props.history.push).toHaveBeenCalledTimes(1); + expect(props.history.push).toHaveBeenCalledWith('/test-team/channels/town-square'); + }); + }); + + test('should display isRequired error', async () => { + renderWithContext( + , + defaultState, + ); + + await userEvent.clear(screen.getByRole('textbox')); + await userEvent.click(screen.getByText('Finish')); + + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); + + test('should display charLength error', async () => { + const checkIfTeamExists = jest.fn(). + mockResolvedValue({data: false}); + + const actions = {...defaultProps.actions, checkIfTeamExists}; + const props = {...defaultProps, actions}; + + const lengthError = `Name must be ${Constants.MIN_TEAMNAME_LENGTH} or more characters up to a maximum of ${Constants.MAX_TEAMNAME_LENGTH}`; + + renderWithContext( + , + defaultState, + ); + + await userEvent.clear(screen.getByRole('textbox')); + + expect(screen.queryByText(lengthError)).not.toBeInTheDocument(); + + await userEvent.type(screen.getByRole('textbox'), 'a'); + await userEvent.click(screen.getByText('Finish')); + + expect(screen.getByText(lengthError)).toBeInTheDocument(); + + await userEvent.clear(screen.getByRole('textbox')); + await userEvent.type(screen.getByRole('textbox'), 'a'.repeat(Constants.MAX_TEAMNAME_LENGTH + 1)); + await userEvent.click(screen.getByText('Finish')); + + expect(screen.getByText(lengthError)).toBeInTheDocument(); + }); + + test('should display teamUrl regex error', async () => { + renderWithContext( + , + defaultState, + ); + + await userEvent.type(screen.getByRole('textbox'), '!!wrongName1'); + await userEvent.click(screen.getByText('Finish')); + + expect(screen.getByText("Use only lower case letters, numbers and dashes. Must start with a letter and can't end in a dash.")).toBeInTheDocument(); + }); + + test('should display teamUrl taken error', async () => { + renderWithContext( + , + defaultState, + ); + + await userEvent.type(screen.getByRole('textbox'), 'channel'); + await userEvent.click(screen.getByText('Finish')); + + expect(screen.getByText('Please try another.', {exact: false})).toBeInTheDocument(); + }); + + test('should focus input when validation error occurs', async () => { + renderWithContext( + , + defaultState, + ); + + const input = screen.getByRole('textbox'); + await userEvent.clear(input); + const focusSpy = jest.spyOn(input, 'focus'); + + // Trigger validation error by submitting empty input + await userEvent.click(screen.getByText('Finish')); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.tsx b/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.tsx new file mode 100644 index 00000000000..b2b6131e675 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/create_team_form.tsx @@ -0,0 +1,316 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import type {Button} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import type {Team} from '@mattermost/types/teams'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import {isAnonymousURLEnabled} from 'selectors/config'; + +import ExternalLink from 'components/external_link'; + +import Constants from 'utils/constants'; +import {cleanUpUrlable} from 'utils/url'; + +import DisplayNameStep from './display_name_step'; +import TeamUrlStep from './team_url_step'; + +type CreateTeamState = { + team?: Partial; + wizard: string; +}; + +type Props = { + step: 'display_name' | 'team_url'; + state: CreateTeamState; + updateParent: (state: CreateTeamState) => void; + actions: { + checkIfTeamExists: (teamName: string) => Promise>; + createTeam: (team: Team) => Promise>; + }; + history: { + push(path: string): void; + }; +}; + +export default function CreateTeamForm({step, state: parentState, updateParent, actions, history}: Props) { + const teamURLInput = useRef(null); + const UseAnonymousURLs = useSelector(isAnonymousURLEnabled); + + const [teamDisplayName, setTeamDisplayName] = useState(parentState.team?.display_name || ''); + const [teamURL, setTeamURL] = useState(parentState.team?.name || ''); + const [nameError, setNameError] = useState(''); + + const isLoadingGuard = useRef(false); + const [isLoading, setIsLoading] = useState(false); + + const isValidTeamName = teamDisplayName.length >= Constants.MIN_TEAMNAME_LENGTH && teamDisplayName.length <= Constants.MAX_TEAMNAME_LENGTH; + + const startLoading = useCallback((): boolean => { + if (isLoadingGuard.current) { + return false; + } + + isLoadingGuard.current = true; + setIsLoading(true); + return true; + }, []); + + const stopLoading = useCallback(() => { + isLoadingGuard.current = false; + setIsLoading(false); + }, []); + + const doCreateTeam = useCallback(async () => { + const {createTeam} = actions; + + if (!startLoading()) { + return; + } + + const teamDraft = { + ...(parentState.team ?? {}), + type: 'O', + display_name: teamDisplayName.trim(), + name: teamURL.trim(), + } as Team; + + const createTeamData = await createTeam(teamDraft); + const data = createTeamData.data; + const error = createTeamData.error; + + if (data) { + history.push('/' + data.name + '/channels/' + Constants.DEFAULT_CHANNEL); + } else if (error) { + setNameError(error.message); + } + + stopLoading(); + }, [actions, history, parentState.team, startLoading, stopLoading, teamDisplayName, teamURL]); + + const submitDisplayName = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + + if (!isValidTeamName) { + return; + } + + if (UseAnonymousURLs) { + doCreateTeam(); + return; + } + + const displayName = teamDisplayName; + const newState = parentState; + newState.wizard = 'team_url'; + newState.team!.display_name = displayName; + newState.team!.name = cleanUpUrlable(displayName); + setTeamURL(newState.team!.name); + + updateParent(newState); + }, [isValidTeamName, UseAnonymousURLs, teamDisplayName, parentState, updateParent, doCreateTeam]); + + const handleDisplayNameChange = useCallback((e: React.ChangeEvent) => { + setTeamDisplayName(e.target.value); + }, []); + + const submitBack = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const newState = parentState; + newState.wizard = 'display_name'; + updateParent(newState); + }, [parentState, updateParent]); + + const teamNameValidations = useCallback( + async (name: string): Promise => { + const {checkIfTeamExists} = actions; + + const cleanedName = cleanUpUrlable(name); + const urlRegex = /^[a-z]+([a-z\-0-9]+|(__)?)[a-z0-9]+$/g; + + if (!name) { + setNameError( + , + ); + teamURLInput.current?.focus(); + return false; + } + + if ( + cleanedName.length < Constants.MIN_TEAMNAME_LENGTH || + cleanedName.length > Constants.MAX_TEAMNAME_LENGTH + ) { + setNameError( + , + ); + teamURLInput.current?.focus(); + return false; + } + + if (cleanedName !== name || !urlRegex.test(name)) { + setNameError( + , + ); + teamURLInput.current?.focus(); + return false; + } + + for ( + let index = 0; + index < Constants.RESERVED_TEAM_NAMES.length; + index++ + ) { + if ( + cleanedName.indexOf( + Constants.RESERVED_TEAM_NAMES[index], + ) === 0 + ) { + setNameError( + ( + + {msg} + + ), + }} + />, + ); + return false; + } + } + + const checkIfTeamExistsData = await checkIfTeamExists(name); + const exists = checkIfTeamExistsData.data; + + if (exists) { + setNameError( + , + ); + stopLoading(); + return false; + } + + return true; + }, + [actions, stopLoading], + ); + + const submitTeamUrl = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + + const teamNameValid = await teamNameValidations(teamURL.trim()); + if (!teamNameValid) { + stopLoading(); + return; + } + + await doCreateTeam(); + }, [teamNameValidations, teamURL, doCreateTeam, stopLoading]); + + const handleFocus = useCallback((e: React.FocusEvent) => { + e.preventDefault(); + e.currentTarget.select(); + }, []); + + const handleTeamURLInputChange = useCallback((e: React.ChangeEvent) => { + setTeamURL(e.target.value); + }, []); + + const buttonText = useMemo(() => { + const createMessage = ( + + ); + + const finishMessage = ( + + ); + + const loadingMessage = ( + + ); + + const nextMessage = ( + <> + + + + ); + + if (UseAnonymousURLs) { + return isLoading ? loadingMessage : createMessage; + } + + if (step === 'team_url') { + return isLoading ? loadingMessage : finishMessage; + } + + return nextMessage; + }, [UseAnonymousURLs, isLoading, step]); + + if (step === 'team_url') { + return ( + + ); + } + + return ( + + ); +} diff --git a/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.test.tsx b/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.test.tsx new file mode 100644 index 00000000000..e9a95ecbf05 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import DisplayNameStep from './display_name_step'; +import type {Props} from './display_name_step'; + +jest.mock('images/logo.png', () => 'logo.png'); + +describe('DisplayNameStep', () => { + const defaultProps: Props = { + teamDisplayName: 'My Team', + isValidTeamName: true, + onDisplayNameChange: jest.fn(), + onSubmit: jest.fn(), + buttonText: <>{'Next'}, + isLoading: false, + nameError: '', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render with default props', () => { + renderWithContext(); + + expect(screen.getByRole('textbox')).toHaveValue('My Team'); + expect(screen.getByRole('button', {name: /next/i})).toBeEnabled(); + expect(screen.getByText('Name your team in any language. Your team name shows in menus and headings.')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('should disable button when team name is invalid', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: /next/i})).toBeDisabled(); + }); + + test('should disable button when isLoading is true', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: /next/i})).toBeDisabled(); + }); + + test('should call onDisplayNameChange when input changes', async () => { + renderWithContext(); + + await userEvent.type(screen.getByRole('textbox'), 'a'); + + expect(defaultProps.onDisplayNameChange).toHaveBeenCalled(); + }); + + test('should call onSubmit when button is clicked', async () => { + renderWithContext(); + + await userEvent.click(screen.getByRole('button', {name: /next/i})); + + expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); + }); + + test('should display error with has-error class when nameError is provided', () => { + const {container} = renderWithContext( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Team name is required'); + expect(container.querySelector('.form-group.has-error')).toBeInTheDocument(); + }); + + test('should render with JSX element as nameError', () => { + renderWithContext( + {'Custom JSX error'}} + />, + ); + + expect(screen.getByText('Custom JSX error')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.tsx b/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.tsx new file mode 100644 index 00000000000..98f1be3cf68 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/display_name_step.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ReactNode} from 'react'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Input from 'components/widgets/inputs/input/input'; + +import logoImage from 'images/logo.png'; +import Constants from 'utils/constants'; + +export type Props = { + teamDisplayName: string; + isValidTeamName: boolean; + onDisplayNameChange: (e: React.ChangeEvent) => void; + onSubmit: (e: React.MouseEvent) => void; + buttonText: ReactNode; + isLoading?: boolean; + nameError: string | JSX.Element; +}; + +export default function DisplayNameStep({teamDisplayName, isValidTeamName, onDisplayNameChange, onSubmit, buttonText, isLoading, nameError}: Props) { + let nameErrorLabel = null; + let nameDivClass = 'form-group'; + if (nameError) { + nameErrorLabel = ( + + ); + nameDivClass += ' has-error'; + } + + return ( +
+
+ {'signup + +
+
+
+ +
+
+ {nameErrorLabel} +
+
+ +
+ +
+
+ ); +} diff --git a/webapp/channels/src/components/create_team/components/team_url/index.ts b/webapp/channels/src/components/create_team/components/create_team_form/index.ts similarity index 81% rename from webapp/channels/src/components/create_team/components/team_url/index.ts rename to webapp/channels/src/components/create_team/components/create_team_form/index.ts index 2aa22856740..71e537b9624 100644 --- a/webapp/channels/src/components/create_team/components/team_url/index.ts +++ b/webapp/channels/src/components/create_team/components/create_team_form/index.ts @@ -7,7 +7,7 @@ import type {Dispatch} from 'redux'; import {checkIfTeamExists, createTeam} from 'mattermost-redux/actions/teams'; -import TeamUrl from './team_url'; +import CreateTeamForm from './create_team_form'; function mapDispatchToProps(dispatch: Dispatch) { return { @@ -18,4 +18,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default connect(null, mapDispatchToProps)(TeamUrl); +export default connect(null, mapDispatchToProps)(CreateTeamForm); diff --git a/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.test.tsx b/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.test.tsx new file mode 100644 index 00000000000..6c34bbbe0f3 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.test.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import TeamUrlStep from './team_url_step'; +import type {Props} from './team_url_step'; + +jest.mock('images/logo.png', () => 'logo.png'); + +describe('TeamUrlStep', () => { + const defaultProps: Props = { + teamURL: 'my-team', + nameError: '', + isLoading: false, + teamURLInput: React.createRef(), + onTeamURLChange: jest.fn(), + onFocus: jest.fn(), + onSubmit: jest.fn(), + onBack: jest.fn(), + buttonText: <>{'Finish'}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render with default props', () => { + const {container} = renderWithContext(); + + expect(screen.getByRole('textbox')).toHaveValue('my-team'); + expect(screen.getByRole('button', {name: /finish/i})).not.toBeDisabled(); + expect(screen.getByText('Short and memorable is best')).toBeInTheDocument(); + expect(screen.getByText('Use lowercase letters, numbers and dashes')).toBeInTheDocument(); + expect(screen.getByText("Must start with a letter and can't end in a dash")).toBeInTheDocument(); + expect(screen.getByText('Back to previous step')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(container.querySelector('.form-group.has-error')).not.toBeInTheDocument(); + }); + + test('should disable button when isLoading is true', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: /finish/i})).toBeDisabled(); + }); + + test('should call onTeamURLChange when input changes', async () => { + renderWithContext(); + + await userEvent.type(screen.getByRole('textbox'), 'a'); + + expect(defaultProps.onTeamURLChange).toHaveBeenCalled(); + }); + + test('should call onSubmit when button is clicked', async () => { + renderWithContext(); + + await userEvent.click(screen.getByRole('button', {name: /finish/i})); + + expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); + }); + + test('should call onBack when back link is clicked', async () => { + renderWithContext(); + + await userEvent.click(screen.getByText('Back to previous step')); + + expect(defaultProps.onBack).toHaveBeenCalledTimes(1); + }); + + test('should display error with has-error class when nameError is provided', () => { + const {container} = renderWithContext( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('This URL is taken'); + expect(container.querySelector('.form-group.has-error')).toBeInTheDocument(); + }); + + test('should render with JSX element as nameError', () => { + renderWithContext( + {'Custom JSX error'}} + />, + ); + + expect(screen.getByText('Custom JSX error')).toBeInTheDocument(); + }); + + test('should call onFocus when input receives focus', () => { + renderWithContext(); + + const input = screen.getByRole('textbox'); + + // The input has autoFocus, so onFocus may already have been called. + // Clear and explicitly trigger focus. + (defaultProps.onFocus as jest.Mock).mockClear(); + input.blur(); + input.focus(); + + expect(defaultProps.onFocus).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.tsx b/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.tsx new file mode 100644 index 00000000000..5aa1e339497 --- /dev/null +++ b/webapp/channels/src/components/create_team/components/create_team_form/team_url_step.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ReactNode} from 'react'; +import React from 'react'; +import {Button} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; + +import WithTooltip from 'components/with_tooltip'; + +import logoImage from 'images/logo.png'; +import {getSiteURL} from 'utils/url'; + +export type Props = { + teamURL?: string; + nameError: string | JSX.Element; + isLoading: boolean; + teamURLInput: React.RefObject; + onTeamURLChange: (e: React.ChangeEvent) => void; + onFocus: (e: React.FocusEvent) => void; + onSubmit: (e: React.MouseEvent) => void; + onBack: (e: React.MouseEvent) => void; + buttonText: ReactNode; +}; + +export default function TeamUrlStep({teamURL, nameError, isLoading, teamURLInput, onTeamURLChange, onFocus, onSubmit, onBack, buttonText}: Props) { + let nameErrorLabel = null; + let nameDivClass = 'form-group'; + if (nameError) { + nameErrorLabel = ( + + ); + nameDivClass += ' has-error'; + } + + const title = `${getSiteURL()}/`; + + return ( +
+
+ {'signup + +
+
+
+
+ + + {title} + + + +
+
+
+ {nameErrorLabel} +
+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +
+
+ + + +
+
+
+ ); +} diff --git a/webapp/channels/src/components/create_team/components/display_name.test.tsx b/webapp/channels/src/components/create_team/components/display_name.test.tsx deleted file mode 100644 index 076374a7691..00000000000 --- a/webapp/channels/src/components/create_team/components/display_name.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import DisplayName from 'components/create_team/components/display_name'; - -import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; -import {cleanUpUrlable} from 'utils/url'; - -jest.mock('images/logo.png', () => 'logo.png'); - -describe('/components/create_team/components/display_name', () => { - const defaultProps = { - updateParent: jest.fn(), - state: { - team: {name: 'test-team', display_name: 'test-team'}, - wizard: 'display_name', - }, - }; - - test('should match snapshot', () => { - const {container} = renderWithContext(); - expect(container).toMatchSnapshot(); - }); - - test('should run updateParent function', async () => { - renderWithContext(); - - await userEvent.click(screen.getByRole('button', {name: /next/i})); - - expect(defaultProps.updateParent).toHaveBeenCalled(); - }); - - test('should pass state to updateParent function', async () => { - renderWithContext(); - - await userEvent.click(screen.getByRole('button', {name: /next/i})); - - expect(defaultProps.updateParent).toHaveBeenCalledWith(expect.objectContaining({ - wizard: 'team_url', - team: expect.objectContaining({ - display_name: 'test-team', - }), - })); - }); - - test('should pass updated team name to updateParent function', async () => { - renderWithContext(); - const teamDisplayName = 'My Test Team'; - const expectedTeam = { - ...defaultProps.state.team, - display_name: teamDisplayName, - name: cleanUpUrlable(teamDisplayName), - }; - - const input = screen.getByRole('textbox'); - await userEvent.clear(input); - await userEvent.type(input, teamDisplayName); - - await userEvent.click(screen.getByRole('button', {name: /next/i})); - - expect(defaultProps.updateParent).toHaveBeenCalledWith(expect.objectContaining({ - wizard: 'team_url', - team: expectedTeam, - })); - }); -}); diff --git a/webapp/channels/src/components/create_team/components/display_name.tsx b/webapp/channels/src/components/create_team/components/display_name.tsx deleted file mode 100644 index cd223f2b7aa..00000000000 --- a/webapp/channels/src/components/create_team/components/display_name.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import type {Team} from '@mattermost/types/teams'; - -import Input from 'components/widgets/inputs/input/input'; - -import logoImage from 'images/logo.png'; -import Constants from 'utils/constants'; -import {cleanUpUrlable} from 'utils/url'; - -type CreateTeamState = { - team?: Partial; - wizard: string; -}; - -type Props = { - - /* - * Object containing team's display_name and name - */ - state: CreateTeamState; - - /* - * Function that updates parent component with state props - */ - updateParent: (state: CreateTeamState) => void; -} - -type State = { - teamDisplayName: string; -} - -export default class TeamSignupDisplayNamePage extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - teamDisplayName: this.props.state.team?.display_name || '', - }; - } - - isValidTeamName = (): boolean => { - return this.state.teamDisplayName.length >= Constants.MIN_TEAMNAME_LENGTH && this.state.teamDisplayName.length <= Constants.MAX_TEAMNAME_LENGTH; - }; - - submitNext = (e: React.MouseEvent): void => { - if (!this.isValidTeamName()) { - return; - } - - e.preventDefault(); - const displayName = this.state.teamDisplayName.trim(); - - const newState = this.props.state; - newState.wizard = 'team_url'; - newState.team!.display_name = displayName; - newState.team!.name = cleanUpUrlable(displayName); - this.props.updateParent(newState); - }; - - handleDisplayNameChange = (e: React.ChangeEvent): void => { - this.setState({teamDisplayName: e.target.value}); - }; - - render(): React.ReactNode { - return ( -
-
- {'signup - -
-
-
- -
-
-
-
- -
- - -
- ); - } -} diff --git a/webapp/channels/src/components/create_team/components/team_url/__snapshots__/team_url.test.tsx.snap b/webapp/channels/src/components/create_team/components/team_url/__snapshots__/team_url.test.tsx.snap deleted file mode 100644 index dd7ca647781..00000000000 --- a/webapp/channels/src/components/create_team/components/team_url/__snapshots__/team_url.test.tsx.snap +++ /dev/null @@ -1,89 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`/components/create_team/components/display_name should match snapshot 1`] = ` -
-
-
- - -
-
-
-
- - http://localhost:8065/ - - -
-
-
-
-

- Choose the web address of your new team: -

-
    -
  • - Short and memorable is best -
  • -
  • - Use lowercase letters, numbers and dashes -
  • -
  • - Must start with a letter and can't end in a dash -
  • -
-
- -
- -
-
-
-`; diff --git a/webapp/channels/src/components/create_team/components/team_url/team_url.test.tsx b/webapp/channels/src/components/create_team/components/team_url/team_url.test.tsx deleted file mode 100644 index 3d3a62a4243..00000000000 --- a/webapp/channels/src/components/create_team/components/team_url/team_url.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import TeamUrl from 'components/create_team/components/team_url/team_url'; - -import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; -import Constants from 'utils/constants'; - -jest.mock('images/logo.png', () => 'logo.png'); - -describe('/components/create_team/components/display_name', () => { - const defaultProps = { - updateParent: jest.fn(), - state: { - team: {name: 'test-team', display_name: 'test-team'}, - wizard: 'display_name', - }, - actions: { - checkIfTeamExists: jest.fn().mockResolvedValue({data: true}), - createTeam: jest.fn().mockResolvedValue({data: {name: 'test-team'}}), - }, - history: {push: jest.fn()}, - }; - - test('should match snapshot', () => { - const {container} = renderWithContext(); - expect(container).toMatchSnapshot(); - }); - - test('should return to display_name.jsx page', async () => { - renderWithContext(); - - await userEvent.click(screen.getByText('Back to previous step')); - - expect(defaultProps.updateParent).toHaveBeenCalledWith({ - ...defaultProps.state, - wizard: 'display_name', - }); - }); - - test('should successfully submit', async () => { - const checkIfTeamExists = jest.fn(). - mockResolvedValueOnce({data: true}). - mockResolvedValue({data: false}); - - const actions = {...defaultProps.actions, checkIfTeamExists}; - const props = {...defaultProps, actions}; - - renderWithContext( - , - ); - - await userEvent.click(screen.getByText('Finish')); - - await waitFor(() => { - expect(screen.getByText('This URL is taken or unavailable. Please try another.')).toBeInTheDocument(); - }); - - expect(actions.checkIfTeamExists).toHaveBeenCalledTimes(1); - expect(actions.createTeam).not.toHaveBeenCalled(); - - await userEvent.click(screen.getByText('Finish')); - - await waitFor(() => { - expect(actions.checkIfTeamExists).toHaveBeenCalledTimes(2); - expect(actions.createTeam).toHaveBeenCalledTimes(1); - expect(actions.createTeam).toHaveBeenCalledWith({display_name: 'test-team', name: 'test-team', type: 'O'}); - expect(props.history.push).toHaveBeenCalledTimes(1); - expect(props.history.push).toHaveBeenCalledWith('/test-team/channels/town-square'); - }); - }); - - test('should display isRequired error', async () => { - renderWithContext( - , - ); - - await userEvent.clear(screen.getByRole('textbox')); - await userEvent.click(screen.getByText('Finish')); - - expect(screen.getByText('This field is required')).toBeInTheDocument(); - }); - - test('should display charLength error', async () => { - const checkIfTeamExists = jest.fn(). - mockResolvedValue({data: false}); - - const actions = {...defaultProps.actions, checkIfTeamExists}; - const props = {...defaultProps, actions}; - - const lengthError = `Name must be ${Constants.MIN_TEAMNAME_LENGTH} or more characters up to a maximum of ${Constants.MAX_TEAMNAME_LENGTH}`; - - renderWithContext( - , - ); - - await userEvent.clear(screen.getByRole('textbox')); - - expect(screen.queryByText(lengthError)).not.toBeInTheDocument(); - - await userEvent.type(screen.getByRole('textbox'), 'a'); - await userEvent.click(screen.getByText('Finish')); - - expect(screen.getByText(lengthError)).toBeInTheDocument(); - - await userEvent.clear(screen.getByRole('textbox')); - await userEvent.type(screen.getByRole('textbox'), 'a'.repeat(Constants.MAX_TEAMNAME_LENGTH + 1)); - await userEvent.click(screen.getByText('Finish')); - - expect(screen.getByText(lengthError)).toBeInTheDocument(); - }); - - test('should display teamUrl regex error', async () => { - renderWithContext( - , - ); - - await userEvent.type(screen.getByRole('textbox'), '!!wrongName1'); - await userEvent.click(screen.getByText('Finish')); - - expect(screen.getByText("Use only lower case letters, numbers and dashes. Must start with a letter and can't end in a dash.")).toBeInTheDocument(); - }); - - test('should display teamUrl taken error', async () => { - renderWithContext( - , - ); - - await userEvent.type(screen.getByRole('textbox'), 'channel'); - await userEvent.click(screen.getByText('Finish')); - - expect(screen.getByText('Please try another.', {exact: false})).toBeInTheDocument(); - }); - - test('should focus input when validation error occurs', async () => { - renderWithContext( - , - ); - - const input = screen.getByRole('textbox'); - await userEvent.clear(input); - const focusSpy = jest.spyOn(input, 'focus'); - - // Trigger validation error by submitting empty input - await userEvent.click(screen.getByText('Finish')); - - expect(focusSpy).toHaveBeenCalled(); - }); -}); diff --git a/webapp/channels/src/components/create_team/components/team_url/team_url.tsx b/webapp/channels/src/components/create_team/components/team_url/team_url.tsx deleted file mode 100644 index 1ec924a4e13..00000000000 --- a/webapp/channels/src/components/create_team/components/team_url/team_url.tsx +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Button} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; - -import type {Team} from '@mattermost/types/teams'; - -import type {ActionResult} from 'mattermost-redux/types/actions'; - -import ExternalLink from 'components/external_link'; -import WithTooltip from 'components/with_tooltip'; - -import logoImage from 'images/logo.png'; -import Constants from 'utils/constants'; -import * as URL from 'utils/url'; - -type State = { - isLoading: boolean; - nameError: string | JSX.Element; - teamURL?: string; -} - -type Props = { - - /* - * Object containing team's display_name and name - */ - state: {team?: Partial; wizard: string}; - - /* - * Function that updates parent component with state props - */ - updateParent: (state: Props['state']) => void; - - /* - * Object with redux action creators - */ - actions: { - - /* - * Action creator to check if a team already exists - */ - checkIfTeamExists: (teamName: string) => Promise>; - - /* - * Action creator to create a new team - */ - createTeam: (team: Team) => Promise>; - }; - history: { - push(path: string): void; - }; -} - -export default class TeamUrl extends React.PureComponent { - teamURLInput: React.RefObject; - - constructor(props: Props) { - super(props); - this.teamURLInput = React.createRef(); - this.state = { - nameError: '', - isLoading: false, - teamURL: props.state.team?.name, - }; - } - - public submitBack = (e: React.MouseEvent) => { - e.preventDefault(); - const newState = this.props.state; - newState.wizard = 'display_name'; - this.props.updateParent(newState); - }; - - public submitNext = async (e: React.MouseEvent) => { - e.preventDefault(); - - const name = this.state.teamURL!.trim(); - const cleanedName = URL.cleanUpUrlable(name); - const urlRegex = /^[a-z]+([a-z\-0-9]+|(__)?)[a-z0-9]+$/g; - const {actions: {checkIfTeamExists, createTeam}} = this.props; - - if (!name) { - this.setState({nameError: ( - ), - }); - this.teamURLInput.current?.focus(); - return; - } - - if (cleanedName.length < Constants.MIN_TEAMNAME_LENGTH || cleanedName.length > Constants.MAX_TEAMNAME_LENGTH) { - this.setState({nameError: ( - ), - }); - this.teamURLInput.current?.focus(); - return; - } - - if (cleanedName !== name || !urlRegex.test(name)) { - this.setState({nameError: ( - ), - }); - this.teamURLInput.current?.focus(); - return; - } - - for (let index = 0; index < Constants.RESERVED_TEAM_NAMES.length; index++) { - if (cleanedName.indexOf(Constants.RESERVED_TEAM_NAMES[index]) === 0) { - this.setState({ - nameError: ( - ( - - {msg} - - ), - }} - /> - ), - }); - return; - } - } - - this.setState({isLoading: true}); - const teamSignup = JSON.parse(JSON.stringify(this.props.state)); - teamSignup.team.type = 'O'; - teamSignup.team.name = name; - - const checkIfTeamExistsData = await checkIfTeamExists(name); - const exists = checkIfTeamExistsData.data; - - if (exists) { - this.setState({nameError: ( - ), - }); - this.setState({isLoading: false}); - return; - } - - const createTeamData = await createTeam(teamSignup.team); - const data = createTeamData.data; - const error = createTeamData.error; - - if (data) { - this.props.history.push('/' + data.name + '/channels/' + Constants.DEFAULT_CHANNEL); - } else if (error) { - this.setState({nameError: error.message}); - this.setState({isLoading: false}); - } - }; - - public handleFocus = (e: React.FocusEvent) => { - e.preventDefault(); - e.currentTarget.select(); - }; - - public handleTeamURLInputChange = (e: React.ChangeEvent) => { - this.setState({teamURL: e.target.value}); - }; - - render() { - let nameError = null; - let nameDivClass = 'form-group'; - if (this.state.nameError) { - nameError = ( - - ); - nameDivClass += ' has-error'; - } - - const title = `${URL.getSiteURL()}/`; - - let finishMessage = ( - - ); - - if (this.state.isLoading) { - finishMessage = ( - - ); - } - - return ( -
-
- {'signup - -
-
-
-
- - - {title} - - - -
-
-
- {nameError} -
-

- -

-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
- -
-
- - - -
-
-
- ); - } -} diff --git a/webapp/channels/src/components/create_team/create_team.test.tsx b/webapp/channels/src/components/create_team/create_team.test.tsx index a123fd6913f..60a0bc9d183 100644 --- a/webapp/channels/src/components/create_team/create_team.test.tsx +++ b/webapp/channels/src/components/create_team/create_team.test.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {createMemoryHistory} from 'history'; import React from 'react'; import {renderWithContext, screen} from 'tests/react_testing_utils'; @@ -55,4 +56,37 @@ describe('component/create_team', () => { expect(screen.getByText('Professional feature')).toBeInTheDocument(); expect(screen.getByText('Your workspace plan has reached the limit on the number of teams. Create unlimited teams with a free 30-day trial. Contact your System Administrator.')).toBeInTheDocument(); }); + + test('should not render team_url route when UseAnonymousURLs is true', () => { + const history = createMemoryHistory({ + initialEntries: ['/create_team/team_url'], + }); + const props = { + ...baseProps, + match: {url: '/create_team'}, + useAnonymousURLs: true, + }; + + renderWithContext(, {}, {history}); + + // With UseAnonymousURLs=true the team_url route is not registered, + // so navigating to it should redirect to display_name + expect(history.location.pathname).toBe('/create_team/display_name'); + }); + + test('should render team_url route when UseAnonymousURLs is false', () => { + const history = createMemoryHistory({ + initialEntries: ['/create_team/team_url'], + }); + const props = { + ...baseProps, + match: {url: '/create_team'}, + useAnonymousURLs: false, + }; + + renderWithContext(, {}, {history}); + + // With UseAnonymousURLs=false the team_url route is available + expect(history.location.pathname).toBe('/create_team/team_url'); + }); }); diff --git a/webapp/channels/src/components/create_team/create_team.tsx b/webapp/channels/src/components/create_team/create_team.tsx index c1c14988f48..2897427ff16 100644 --- a/webapp/channels/src/components/create_team/create_team.tsx +++ b/webapp/channels/src/components/create_team/create_team.tsx @@ -13,8 +13,7 @@ import type {Team} from '@mattermost/types/teams'; import AnnouncementBar from 'components/announcement_bar'; import BackButton from 'components/common/back_button'; import SiteNameAndDescription from 'components/common/site_name_and_description'; -import DisplayName from 'components/create_team/components/display_name'; -import TeamUrl from 'components/create_team/components/team_url'; +import CreateTeamForm from 'components/create_team/components/create_team_form'; export type Props = { @@ -49,6 +48,7 @@ export type Props = { isFreeTrial: boolean; usageDeltas: CloudUsage; intl: IntlShape; + useAnonymousURLs?: boolean; }; type State = { @@ -140,23 +140,29 @@ export class CreateTeam extends React.PureComponent ( - - )} - /> - ( - )} /> + + { + !this.props.useAnonymousURLs && + ( + + )} + /> + } )} diff --git a/webapp/channels/src/components/create_team/index.ts b/webapp/channels/src/components/create_team/index.ts index c2b29854e72..02941f5b22a 100644 --- a/webapp/channels/src/components/create_team/index.ts +++ b/webapp/channels/src/components/create_team/index.ts @@ -8,6 +8,8 @@ import {getCloudSubscription as selectCloudSubscription} from 'mattermost-redux/ import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +import {isAnonymousURLEnabled} from 'selectors/config'; + import withUseGetUsageDelta from 'components/common/hocs/cloud/with_use_get_usage_deltas'; import {isCloudLicense} from 'utils/license_utils'; @@ -30,6 +32,8 @@ function mapStateToProps(state: GlobalState) { const isCloud = isCloudLicense(license); const isFreeTrial = subscription?.is_free_trial === 'true'; + const useAnonymousURLs = isAnonymousURLEnabled(state); + return { currentChannel, currentTeam, @@ -37,6 +41,7 @@ function mapStateToProps(state: GlobalState) { siteName, isCloud, isFreeTrial, + useAnonymousURLs, }; } diff --git a/webapp/channels/src/components/dot_menu/dot_menu.tsx b/webapp/channels/src/components/dot_menu/dot_menu.tsx index 94ee5a5f16f..82b798b6715 100644 --- a/webapp/channels/src/components/dot_menu/dot_menu.tsx +++ b/webapp/channels/src/components/dot_menu/dot_menu.tsx @@ -455,7 +455,7 @@ export class DotMenuClass extends React.PureComponent { this.handleDeleteMenuItemActivated(); break; - // move thread + // move thread case Keyboard.isKeyPressed(event, Constants.KeyCodes.W): if (this.props.canMove) { forceCloseMenu(); diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx index 5459ae571e9..8b87b5b75a1 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx @@ -29,7 +29,9 @@ describe('components/new_channel_modal', () => { const initialState: DeepPartial = { entities: { general: { - config: {}, + config: { + UseAnonymousURLs: 'false', + }, }, channels: { currentChannelId: 'current_channel_id', @@ -83,14 +85,10 @@ describe('components/new_channel_modal', () => { permissions: [], }, team_user: { - permissions: [ - Permissions.CREATE_PRIVATE_CHANNEL, - ], + permissions: [Permissions.CREATE_PRIVATE_CHANNEL], }, system_admin: { - permissions: [ - Permissions.CREATE_PUBLIC_CHANNEL, - ], + permissions: [Permissions.CREATE_PUBLIC_CHANNEL], }, system_user: { permissions: [], diff --git a/webapp/channels/src/components/suggestion/suggestion_list_contents.tsx b/webapp/channels/src/components/suggestion/suggestion_list_contents.tsx index 0a745ff04be..1ebc4f8eaa0 100644 --- a/webapp/channels/src/components/suggestion/suggestion_list_contents.tsx +++ b/webapp/channels/src/components/suggestion/suggestion_list_contents.tsx @@ -46,7 +46,6 @@ const SuggestionListContents = React.forwardRef setItemRef(term, ref) : undefined; - return ( { const idx = generateIndex(AdminDefinition, intl, {[samplePlugin1.id]: samplePlugin1, [samplePlugin2.id]: samplePlugin2}); - expect(idx.search('random')).toEqual(['plugin_Some-random-plugin', 'site_config/public_links']); + expect(idx.search('random')).toEqual(['site_config/users_and_teams', 'plugin_Some-random-plugin', 'site_config/public_links']); expect(idx.search('autolink')).toEqual(['plugin_mattermost-autolink']); }); }); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 76fb84a6903..8362ee35f72 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -228,6 +228,7 @@ export type ClientConfig = { ScheduledPosts: string; DeleteAccountLink: string; ContentFlaggingEnabled: 'true' | 'false'; + UseAnonymousURLs: string; // Burn on Read Settings EnableBurnOnRead: string; @@ -637,6 +638,7 @@ export type RateLimitSettings = { export type PrivacySettings = { ShowEmailAddress: boolean; ShowFullName: boolean; + UseAnonymousURLs: boolean; }; export type SupportSettings = { From 5646f4aa5c8c0829c22639f5202f59bf1673a8ab Mon Sep 17 00:00:00 2001 From: Bill Gardner Date: Thu, 12 Mar 2026 11:40:38 -0400 Subject: [PATCH 3/5] Update plugin-calls to v1.11.4 (#35561) --- server/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Makefile b/server/Makefile index d7628ec3576..8b8c77af2ee 100644 --- a/server/Makefile +++ b/server/Makefile @@ -154,7 +154,7 @@ TEMPLATES_DIR=templates # Plugins Packages PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) -PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.1 +PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4 PLUGIN_PACKAGES += mattermost-plugin-github-v2.6.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1 From 2efee7ec283d2dbfd5549c7babff6d510a9cb0c3 Mon Sep 17 00:00:00 2001 From: Maria A Nunez Date: Thu, 12 Mar 2026 12:50:53 -0400 Subject: [PATCH 4/5] Add single-channel guests filter and channel count column to System Console Users (#35517) * Add single-channel guests filter and channel count column to System Console Users - Add guest_filter query parameter to Reports API with store-level filtering by guest channel membership count (all, single_channel, multi_channel) - Add channel_count field to user report responses and CSV exports - Add grouped guest role filter options in the filter popover - Add toggleable Channel count column to the users table - Add GuestFilter and SearchTerm to Go client GetUsersForReporting - Add tests: API parsing, API integration, app job dedup, webapp utils, E2E column data rendering Made-with: Cursor * Fix gofmt alignment and isolate guest store tests - Align GuestFilter constants to satisfy gofmt - Move guest user/channel setup into a nested sub-test to avoid breaking existing ordering and role filter assertions Made-with: Cursor * Exclude archived channels from guest filter queries and ChannelCount The ChannelMembers subqueries for guest_filter (single/multi channel) and the ChannelCount column did not join with Channels to check DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but leaves ChannelMembers rows intact, archived channel memberships were incorrectly counted, potentially misclassifying guests between single-channel and multi-channel filters and inflating ChannelCount. - Join ChannelMembers with Channels (DeleteAt = 0) in all three subqueries in applyUserReportFilter and GetUserReport - Add store test covering archived channel exclusion - Tighten existing guest filter test assertions with found-flags and exact count checks Made-with: Cursor * Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider - Scope ChannelCount and guest filter subqueries to Open/Private channel types only (exclude DM and GM), so a guest with one team channel plus a DM is correctly classified as single-channel - Add GuestFilter validation in UserReportOptions.IsValid with AllowedGuestFilters whitelist - Add API test for invalid guest_filter rejection (400) - Add store regression test for DM/GM exclusion - Fix role filter dropdown: hide the divider above the first group heading via CSS rule on DropDown__group:first-child - Update E2E test label to match "Guests in a single channel" wording Made-with: Cursor * Add store test coverage for private and GM channel types Private channels (type P) should be counted in ChannelCount and guest filters, while GM channels (type G) should not. Add a test that creates a guest with memberships in an open channel, a private channel, and a GM, then asserts ChannelCount = 2, multi-channel filter includes the guest, and single-channel filter excludes them. Made-with: Cursor * Add server i18n translation for invalid_guest_filter error The new error ID model.user_report_options.is_valid.invalid_guest_filter was missing from server/i18n/en.json, causing CI to fail. Made-with: Cursor * Make filter dropdown dividers full width Remove the horizontal inset from grouped dropdown separators so the system user role filter dividers span edge to edge across the menu. Leave the unrelated webapp/package-lock.json change uncommitted. Made-with: Cursor * Optimize guest channel report filters. Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests. --- .../sections/user_management/users/menus.ts | 8 +- .../user_management/users/users_table.ts | 5 + .../system_users/column_toggler.spec.ts | 115 ++++- .../system_users/filter_popover.spec.ts | 176 ++++++- server/channels/api4/report.go | 1 + server/channels/api4/report_test.go | 102 ++++ server/channels/app/report.go | 4 +- server/channels/app/report_test.go | 36 ++ .../export_users_to_csv.go | 2 + server/channels/store/sqlstore/user_store.go | 17 +- server/channels/store/storetest/user_store.go | 464 ++++++++++++++++++ server/i18n/en.json | 4 + server/public/model/client4.go | 6 + server/public/model/report.go | 19 + .../system_users/constants/index.ts | 5 +- .../system_users/system_users.tsx | 15 + .../index.tsx | 7 + .../system_users_filters_popover/index.tsx | 8 +- .../system_users_filter_role/index.tsx | 95 ++-- .../system_users/utils/index.test.ts | 74 ++- .../system_users/utils/index.tsx | 17 +- .../src/components/dropdown_input.scss | 4 + .../src/components/dropdown_input.tsx | 13 + webapp/channels/src/i18n/en.json | 5 +- webapp/channels/src/reducers/views/admin.ts | 4 +- webapp/platform/types/src/reports.ts | 8 + 26 files changed, 1162 insertions(+), 52 deletions(-) diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/menus.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/menus.ts index 658a470b2df..123c0464439 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/menus.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/menus.ts @@ -50,7 +50,13 @@ export class ColumnToggleMenu { } } -type RoleFilter = 'Any' | 'System Admin' | 'Member' | 'Guest'; +type RoleFilter = + | 'Any' + | 'System Admin' + | 'Member' + | 'Guests (all)' + | 'Guests in a single channel' + | 'Guests in multiple channels'; type StatusFilter = 'Any' | 'Activated users' | 'Deactivated users'; /** diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts index feb9d1c840a..1bf6f7befd2 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts @@ -22,6 +22,7 @@ export class UsersTable { readonly lastPostHeader: Locator; readonly daysActiveHeader: Locator; readonly messagesPostedHeader: Locator; + readonly channelCountHeader: Locator; readonly actionsHeader: Locator; constructor(container: Locator) { @@ -38,6 +39,7 @@ export class UsersTable { this.lastPostHeader = container.locator('#systemUsersTable-header-lastPostDateColumn'); this.daysActiveHeader = container.locator('#systemUsersTable-header-daysActiveColumn'); this.messagesPostedHeader = container.locator('#systemUsersTable-header-totalPostsColumn'); + this.channelCountHeader = container.locator('#systemUsersTable-header-channelCountColumn'); this.actionsHeader = container.locator('#systemUsersTable-header-actionsColumn'); } @@ -65,6 +67,7 @@ export class UsersTable { 'Last post': this.lastPostHeader, 'Days active': this.daysActiveHeader, 'Messages posted': this.messagesPostedHeader, + 'Channel count': this.channelCountHeader, Actions: this.actionsHeader, }; const header = headerMap[columnName]; @@ -143,6 +146,7 @@ export class UserRow { readonly lastPostCell: Locator; readonly daysActiveCell: Locator; readonly messagesPostedCell: Locator; + readonly channelCountCell: Locator; readonly actionsCell: Locator; // User details components @@ -168,6 +172,7 @@ export class UserRow { this.lastPostCell = container.locator('.lastPostDateColumn'); this.daysActiveCell = container.locator('.daysActiveColumn'); this.messagesPostedCell = container.locator('.totalPostsColumn'); + this.channelCountCell = container.locator('.channelCountColumn'); this.actionsCell = container.locator('.actionsColumn'); this.profilePicture = this.userDetailsCell.locator('.profilePicture'); diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/column_toggler.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/column_toggler.spec.ts index 2d744ed0de1..04d0a7a512a 100644 --- a/e2e-tests/playwright/specs/functional/system_console/system_users/column_toggler.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/column_toggler.spec.ts @@ -29,7 +29,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord const menuItemsTexts = await menuItems.allInnerTexts(); // * Verify menu items exists in the correct order - expect(menuItemsTexts).toHaveLength(9); + expect(menuItemsTexts).toHaveLength(10); expect(menuItemsTexts).toEqual([ 'User details', 'Email', @@ -39,6 +39,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord 'Last post', 'Days active', 'Messages posted', + 'Channel count', 'Actions', ]); }); @@ -124,3 +125,115 @@ test('MM-T5523-5 Should show/hide the columns which are toggled on/off', async ( // * Verify that however Last login column is still hidden as we did not check it on await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Last login'})).not.toBeVisible(); }); + +/** + * @objective Verify that the Channel count column displays a numeric value for a user with known channel memberships + * + * @precondition + * A guest user exists with exactly two channel memberships + */ +test( + 'displays numeric channel count value when Channel count column is enabled', + {tag: '@system_users'}, + async ({pw}) => { + const {adminUser, adminClient, team} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Create two channels + const ch1Name = `count-ch1-${await pw.random.id()}`; + const channel1 = await adminClient.createChannel({ + team_id: team.id, + name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''), + display_name: ch1Name, + type: 'O', + }); + + const ch2Name = `count-ch2-${await pw.random.id()}`; + const channel2 = await adminClient.createChannel({ + team_id: team.id, + name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''), + display_name: ch2Name, + type: 'O', + }); + + // # Create a guest user and add to exactly two channels + const guestUser = await adminClient.createUser(await pw.random.user(), '', ''); + await adminClient.updateUserRoles(guestUser.id, 'system_guest'); + await adminClient.addToTeam(team.id, guestUser.id); + await adminClient.addToChannel(guestUser.id, channel1.id); + await adminClient.addToChannel(guestUser.id, channel2.id); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Users section + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + // # Enable Channel count column + const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu(); + await columnToggleMenu.clickMenuItem('Channel count'); + await columnToggleMenu.close(); + + // * Verify Channel count column header is visible + await expect( + systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}), + ).toBeVisible(); + + // # Search for the guest user + await systemConsolePage.users.searchUsers(guestUser.email); + await systemConsolePage.users.isLoadingComplete(); + + // * Verify the Channel count cell displays the expected numeric value + const firstRow = systemConsolePage.users.container.locator('tbody tr').first(); + const channelCountCell = firstRow.locator('.channelCountColumn'); + await expect(channelCountCell).toHaveText('2'); + }, +); + +/** + * @objective Verify that the Channel count column can be toggled on and off + */ +test('toggles Channel count column visibility on and off', {tag: '@system_users'}, async ({pw}) => { + const {adminUser} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Users section + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + // # Open the column toggle menu and enable Channel count + let columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu(); + await columnToggleMenu.clickMenuItem('Channel count'); + await columnToggleMenu.close(); + + // * Verify Channel count column header is visible + await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'})).toBeVisible(); + + // # Open column toggle menu again and disable Channel count + columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu(); + await columnToggleMenu.clickMenuItem('Channel count'); + await columnToggleMenu.close(); + + // * Verify Channel count column header is hidden + await expect( + systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}), + ).not.toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/filter_popover.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/filter_popover.spec.ts index 2ea663e00b0..ce3f4c9a387 100644 --- a/e2e-tests/playwright/specs/functional/system_console/system_users/filter_popover.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/filter_popover.spec.ts @@ -78,7 +78,7 @@ test('MM-T5521-8 Should be able to filter users with role filter', async ({pw}) const filterPopover = await systemConsolePage.users.openFilterPopover(); // # Open the role filter in the popover and select Guest - await filterPopover.filterByRole('Guest'); + await filterPopover.filterByRole('Guests (all)'); // # Save the filter and close the popover await filterPopover.save(); @@ -145,3 +145,177 @@ test('MM-T5521-9 Should be able to filter users with status filter', async ({pw} // * Verify that regular user is not visible as 'Deactivated' status filter was applied await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible(); }); + +/** + * @objective Verify that the role filter dropdown shows all guest filter variants + */ +test('displays all guest filter variants in the role filter dropdown', {tag: '@system_users'}, async ({pw}) => { + const {adminUser} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Users section + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + // # Open the filter popover + const filterPopover = await systemConsolePage.users.openFilterPopover(); + + // # Open the role filter menu + await filterPopover.openRoleMenu(); + + // * Verify all 6 role filter options are present + const roleOptions = filterPopover.container.getByRole('option'); + const roleTexts = await roleOptions.allInnerTexts(); + + expect(roleTexts).toEqual([ + 'Any', + 'System Admin', + 'Member', + 'Guests (all)', + 'Guests in a single channel', + 'Guests in multiple channels', + ]); +}); + +/** + * @objective Verify that filtering by single-channel guest filter returns only guests with exactly one channel membership + * + * @precondition + * A guest user exists with exactly one channel membership + */ +test( + 'filters users by single-channel guest filter and shows only single-channel guests', + {tag: '@system_users'}, + async ({pw}) => { + const {adminUser, adminClient, team} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Create a channel + const channelName = `guest-ch-${await pw.random.id()}`; + const channel = await adminClient.createChannel({ + team_id: team.id, + name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''), + display_name: channelName, + type: 'O', + }); + + // # Create a guest user and add to exactly one channel + const guestUser = await adminClient.createUser(await pw.random.user(), '', ''); + await adminClient.updateUserRoles(guestUser.id, 'system_guest'); + await adminClient.addToTeam(team.id, guestUser.id); + await adminClient.addToChannel(guestUser.id, channel.id); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Users section + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + // # Open the filter popover and filter by single-channel guests + const filterPopover = await systemConsolePage.users.openFilterPopover(); + await filterPopover.filterByRole('Guests in a single channel'); + await filterPopover.save(); + await systemConsolePage.users.isLoadingComplete(); + + // # Search for the guest user + await systemConsolePage.users.searchUsers(guestUser.email); + + // * Verify the single-channel guest is visible + await expect(systemConsolePage.users.container.getByText(guestUser.email)).toBeVisible(); + }, +); + +/** + * @objective Verify that filtering by multi-channel guest filter returns only guests with more than one channel membership + * + * @precondition + * A guest user exists with two channel memberships and another with one + */ +test( + 'filters users by multi-channel guest filter and excludes single-channel guests', + {tag: '@system_users'}, + async ({pw}) => { + const {adminUser, adminClient, team} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Create two channels + const ch1Name = `guest-multi-1-${await pw.random.id()}`; + const channel1 = await adminClient.createChannel({ + team_id: team.id, + name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''), + display_name: ch1Name, + type: 'O', + }); + + const ch2Name = `guest-multi-2-${await pw.random.id()}`; + const channel2 = await adminClient.createChannel({ + team_id: team.id, + name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''), + display_name: ch2Name, + type: 'O', + }); + + // # Create a guest user with 2 channel memberships + const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', ''); + await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest'); + await adminClient.addToTeam(team.id, multiChannelGuest.id); + await adminClient.addToChannel(multiChannelGuest.id, channel1.id); + await adminClient.addToChannel(multiChannelGuest.id, channel2.id); + + // # Create a guest user with only 1 channel membership + const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', ''); + await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest'); + await adminClient.addToTeam(team.id, singleChannelGuest.id); + await adminClient.addToChannel(singleChannelGuest.id, channel1.id); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Users section + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + // # Open the filter popover and filter by multi-channel guests + const filterPopover = await systemConsolePage.users.openFilterPopover(); + await filterPopover.filterByRole('Guests in multiple channels'); + await filterPopover.save(); + await systemConsolePage.users.isLoadingComplete(); + + // # Search for the multi-channel guest + await systemConsolePage.users.searchUsers(multiChannelGuest.email); + + // * Verify the multi-channel guest is visible + await expect(systemConsolePage.users.container.getByText(multiChannelGuest.email)).toBeVisible(); + + // # Search for the single-channel guest + await systemConsolePage.users.searchUsers(singleChannelGuest.email); + + // * Verify the single-channel guest is NOT visible + await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible(); + }, +); diff --git a/server/channels/api4/report.go b/server/channels/api4/report.go index 3e5301130b2..7642d4049bf 100644 --- a/server/channels/api4/report.go +++ b/server/channels/api4/report.go @@ -154,6 +154,7 @@ func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model. HideActive: hideActive, HideInactive: hideInactive, SearchTerm: values.Get("search_term"), + GuestFilter: values.Get("guest_filter"), }, nil } diff --git a/server/channels/api4/report_test.go b/server/channels/api4/report_test.go index d7e47d798c3..00ca1ca9d67 100644 --- a/server/channels/api4/report_test.go +++ b/server/channels/api4/report_test.go @@ -85,6 +85,69 @@ func TestGetUsersForReporting(t *testing.T) { require.Error(t, err) CheckBadRequestStatus(t, resp) }) + + t.Run("should filter by guest_filter single_channel and return channel_count", func(t *testing.T) { + th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId) + + // Create a guest user with exactly one channel membership + singleChannelGuest := th.CreateUser(t) + _, appErr := th.App.UpdateUserRoles(th.Context, singleChannelGuest.Id, model.SystemGuestRoleId, false) + require.Nil(t, appErr) + _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, singleChannelGuest.Id, "") + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, singleChannelGuest, th.BasicChannel, false) + require.Nil(t, appErr) + + // Create a guest user with two channel memberships + multiChannelGuest := th.CreateUser(t) + _, appErr = th.App.UpdateUserRoles(th.Context, multiChannelGuest.Id, model.SystemGuestRoleId, false) + require.Nil(t, appErr) + _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, multiChannelGuest.Id, "") + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel, false) + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel2, false) + require.Nil(t, appErr) + + options := &model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + PageSize: 100, + }, + GuestFilter: model.GuestFilterSingleChannel, + } + + userReports, resp, err := client.GetUsersForReporting(context.Background(), options) + require.NoError(t, err) + CheckOKStatus(t, resp) + + foundSingle := false + for _, report := range userReports { + require.Contains(t, report.Roles, "system_guest") + require.NotNil(t, report.ChannelCount) + + if report.Id == singleChannelGuest.Id { + foundSingle = true + require.Equal(t, 1, *report.ChannelCount) + } + require.NotEqual(t, multiChannelGuest.Id, report.Id) + } + require.True(t, foundSingle, "single-channel guest not found in results") + }) + + t.Run("should reject invalid guest_filter value", func(t *testing.T) { + th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId) + + options := &model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + PageSize: 50, + }, + GuestFilter: "invalid_value", + } + + _, resp, err := client.GetUsersForReporting(context.Background(), options) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) } func TestFillReportingBaseOptions(t *testing.T) { @@ -182,6 +245,45 @@ func TestFillUserReportOptions(t *testing.T) { require.Equal(t, validTeamID, options.Team) }) + + t.Run("guest_filter all", func(t *testing.T) { + values := url.Values{} + values.Set("guest_filter", "all") + + options, appErr := fillUserReportOptions(values) + + require.Nil(t, appErr) + require.Equal(t, "all", options.GuestFilter) + }) + + t.Run("guest_filter single_channel", func(t *testing.T) { + values := url.Values{} + values.Set("guest_filter", "single_channel") + + options, appErr := fillUserReportOptions(values) + + require.Nil(t, appErr) + require.Equal(t, "single_channel", options.GuestFilter) + }) + + t.Run("guest_filter multi_channel", func(t *testing.T) { + values := url.Values{} + values.Set("guest_filter", "multi_channel") + + options, appErr := fillUserReportOptions(values) + + require.Nil(t, appErr) + require.Equal(t, "multi_channel", options.GuestFilter) + }) + + t.Run("guest_filter defaults to empty when not provided", func(t *testing.T) { + values := url.Values{} + + options, appErr := fillUserReportOptions(values) + + require.Nil(t, appErr) + require.Equal(t, "", options.GuestFilter) + }) } func TestGetPostsForReporting(t *testing.T) { diff --git a/server/channels/app/report.go b/server/channels/app/report.go index b91c45df964..9866469b52f 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -214,6 +214,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOption "hide_inactive": strconv.FormatBool(ro.HideInactive), "start_at": strconv.FormatInt(startAt, 10), "end_at": strconv.FormatInt(endAt, 10), + "guest_filter": ro.GuestFilter, } // Check for existing jobs @@ -269,7 +270,8 @@ func (a *App) checkForExistingJobs(rctx request.CTX, options map[string]string, job.Data["role"] == options["role"] && job.Data["team"] == options["team"] && job.Data["hide_active"] == options["hide_active"] && - job.Data["hide_inactive"] == options["hide_inactive"] { + job.Data["hide_inactive"] == options["hide_inactive"] && + job.Data["guest_filter"] == options["guest_filter"] { return true } } diff --git a/server/channels/app/report_test.go b/server/channels/app/report_test.go index 3c591f785cc..cb65b3770ec 100644 --- a/server/channels/app/report_test.go +++ b/server/channels/app/report_test.go @@ -120,6 +120,7 @@ func TestCheckForExistingJobs(t *testing.T) { "team": "", "hide_active": "false", "hide_inactive": "false", + "guest_filter": "", } jobType := model.JobTypeExportUsersToCSV @@ -147,6 +148,7 @@ func TestCheckForExistingJobs(t *testing.T) { "team": "", "hide_active": "false", "hide_inactive": "false", + "guest_filter": "", } jobType := model.JobTypeExportUsersToCSV @@ -178,6 +180,7 @@ func TestCheckForExistingJobs(t *testing.T) { "team": "", "hide_active": "false", "hide_inactive": "false", + "guest_filter": "", } jobType := model.JobTypeExportUsersToCSV @@ -189,6 +192,7 @@ func TestCheckForExistingJobs(t *testing.T) { "team": "", "hide_active": "false", "hide_inactive": "false", + "guest_filter": "", } job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, differentOptions) @@ -199,4 +203,36 @@ func TestCheckForExistingJobs(t *testing.T) { appErr := app.checkForExistingJobs(th.Context, options, jobType) require.Nil(t, appErr) }) + + t.Run("should not return error if existing job has different guest_filter", func(t *testing.T) { + app := th.App + options := map[string]string{ + "date_range": "last_30_days", + "requesting_user_id": th.BasicUser.Id, + "role": "", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + "guest_filter": "single_channel", + } + + jobType := model.JobTypeExportUsersToCSV + + existingJobOptions := map[string]string{ + "date_range": "last_30_days", + "requesting_user_id": th.BasicUser.Id, + "role": "", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + "guest_filter": "multi_channel", + } + + job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, existingJobOptions) + require.Nil(t, err) + require.NotNil(t, job) + + appErr := app.checkForExistingJobs(th.Context, options, jobType) + require.Nil(t, appErr) + }) } diff --git a/server/channels/jobs/export_users_to_csv/export_users_to_csv.go b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go index 77238f81c03..0c89a929a88 100644 --- a/server/channels/jobs/export_users_to_csv/export_users_to_csv.go +++ b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go @@ -43,6 +43,7 @@ func MakeWorker(jobServer *jobs.JobServer, store store.Store, app ExportUsersToC "LastPostDate", "DaysActive", "TotalPosts", + "ChannelCount", "DeletedAt", }, getData(app), @@ -90,6 +91,7 @@ func parseJobMetadata(data model.StringMap) (*model.UserReportOptions, error) { HideActive: hideActive, Role: data["role"], Team: data["team"], + GuestFilter: data["guest_filter"], } return &options, nil diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index 8f80efaeb6a..1cc6097d1fb 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -2379,7 +2379,19 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error { } func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions) sq.SelectBuilder { - query = applyRoleFilter(query, filter.Role) + switch filter.GuestFilter { + case model.GuestFilterAll: + query = applyRoleFilter(query, "system_guest") + case model.GuestFilterSingleChannel: + query = applyRoleFilter(query, "system_guest") + query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) = 1")) + case model.GuestFilterMultipleChannel: + query = applyRoleFilter(query, "system_guest") + query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) > 1")) + default: + query = applyRoleFilter(query, filter.Role) + } + if filter.HasNoTeam { query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)")) } else if filter.Team != "" { @@ -2426,6 +2438,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. "MAX(ps.LastPostDate) AS LastPostDate", "COUNT(ps.Day) AS DaysActive", "SUM(ps.NumPosts) AS TotalPosts", + "(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) AS ChannelCount", ) sortDirection := "ASC" @@ -2502,7 +2515,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. } parentQuery = us.getQueryBuilder(). - Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts")...). + Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts", "ChannelCount")...). FromSelect(query, "data"). OrderBy(filter.SortColumn+" "+reverseSortDirection, "Id") } diff --git a/server/channels/store/storetest/user_store.go b/server/channels/store/storetest/user_store.go index 7ffa3d167f4..4908776d4b4 100644 --- a/server/channels/store/storetest/user_store.go +++ b/server/channels/store/storetest/user_store.go @@ -6843,6 +6843,470 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor require.NoError(t, err) require.Len(t, userReport, 11) }) + + t.Run("guest channel count and filters", func(t *testing.T) { + guestChannel1, chErr := ss.Channel().Save(rctx, &model.Channel{ + TeamId: team.Id, + DisplayName: "Guest Channel 1", + Name: "guest_channel_1_" + model.NewId(), + Type: model.ChannelTypeOpen, + }, 100) + require.NoError(t, chErr) + guestChannel2, chErr := ss.Channel().Save(rctx, &model.Channel{ + TeamId: team.Id, + DisplayName: "Guest Channel 2", + Name: "guest_channel_2_" + model.NewId(), + Type: model.ChannelTypeOpen, + }, 100) + require.NoError(t, chErr) + + guestNoChannels := &model.User{Username: "zguest_nochannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestNoChannels, gErr := ss.User().Save(rctx, guestNoChannels) + require.NoError(t, gErr) + + guestOneChannel := &model.User{Username: "zguest_onechannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestOneChannel, gErr = ss.User().Save(rctx, guestOneChannel) + require.NoError(t, gErr) + + guestTwoChannels := &model.User{Username: "zguest_twochannels_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestTwoChannels, gErr = ss.User().Save(rctx, guestTwoChannels) + require.NoError(t, gErr) + + _, mErr := ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel1.Id, + UserId: guestOneChannel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel1.Id, + UserId: guestTwoChannels.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel2.Id, + UserId: guestTwoChannels.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + defer func() { + require.NoError(t, ss.User().PermanentDelete(rctx, guestNoChannels.Id)) + require.NoError(t, ss.User().PermanentDelete(rctx, guestOneChannel.Id)) + require.NoError(t, ss.User().PermanentDelete(rctx, guestTwoChannels.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel1.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel2.Id)) + }() + + t.Run("should return channel count for users", func(t *testing.T) { + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + }) + require.NoError(t, rErr) + require.NotNil(t, userReport) + + foundOne, foundTwo, foundNone := false, false, false + for _, report := range userReport { + if report.Username == guestOneChannel.Username { + foundOne = true + require.NotNil(t, report.ChannelCount) + require.Equal(t, 1, *report.ChannelCount) + } + if report.Username == guestTwoChannels.Username { + foundTwo = true + require.NotNil(t, report.ChannelCount) + require.Equal(t, 2, *report.ChannelCount) + } + if report.Username == guestNoChannels.Username { + foundNone = true + require.NotNil(t, report.ChannelCount) + require.Equal(t, 0, *report.ChannelCount) + } + } + require.True(t, foundOne, "guestOneChannel not found in report") + require.True(t, foundTwo, "guestTwoChannels not found in report") + require.True(t, foundNone, "guestNoChannels not found in report") + }) + + t.Run("guest filter all should return all guests", func(t *testing.T) { + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterAll, + }) + require.NoError(t, rErr) + require.NotNil(t, userReport) + + foundNone, foundOne, foundTwo := false, false, false + for _, report := range userReport { + require.Contains(t, report.Roles, "system_guest") + switch report.Username { + case guestNoChannels.Username: + foundNone = true + case guestOneChannel.Username: + foundOne = true + case guestTwoChannels.Username: + foundTwo = true + } + } + require.True(t, foundNone, "guestNoChannels not found in guest-all filter") + require.True(t, foundOne, "guestOneChannel not found in guest-all filter") + require.True(t, foundTwo, "guestTwoChannels not found in guest-all filter") + require.Equal(t, 3, len(userReport)) + }) + + t.Run("guest filter single_channel should return guests with exactly 1 channel", func(t *testing.T) { + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterSingleChannel, + }) + require.NoError(t, rErr) + require.NotNil(t, userReport) + + found := false + for _, report := range userReport { + require.Contains(t, report.Roles, "system_guest") + if report.Username == guestOneChannel.Username { + found = true + require.NotNil(t, report.ChannelCount) + require.Equal(t, 1, *report.ChannelCount) + } + require.NotEqual(t, guestNoChannels.Username, report.Username) + require.NotEqual(t, guestTwoChannels.Username, report.Username) + } + require.True(t, found, "single-channel guest not found in results") + }) + + t.Run("guest filter multi_channel should return guests with more than 1 channel", func(t *testing.T) { + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterMultipleChannel, + }) + require.NoError(t, rErr) + require.NotNil(t, userReport) + + found := false + for _, report := range userReport { + require.Contains(t, report.Roles, "system_guest") + if report.Username == guestTwoChannels.Username { + found = true + require.NotNil(t, report.ChannelCount) + require.Equal(t, 2, *report.ChannelCount) + } + require.NotEqual(t, guestNoChannels.Username, report.Username) + require.NotEqual(t, guestOneChannel.Username, report.Username) + } + require.True(t, found, "multi-channel guest not found in results") + }) + + t.Run("archived channel should not count toward channel memberships", func(t *testing.T) { + archivedChannel, chErr := ss.Channel().Save(rctx, &model.Channel{ + TeamId: team.Id, + DisplayName: "Archived Channel", + Name: "archived_channel_" + model.NewId(), + Type: model.ChannelTypeOpen, + }, 100) + require.NoError(t, chErr) + + guestWithArchived := &model.User{Username: "zguest_archived_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestWithArchived, gErr = ss.User().Save(rctx, guestWithArchived) + require.NoError(t, gErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel1.Id, + UserId: guestWithArchived.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: archivedChannel.Id, + UserId: guestWithArchived.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + err := ss.Channel().Delete(archivedChannel.Id, model.GetMillis()) + require.NoError(t, err) + + defer func() { + require.NoError(t, ss.User().PermanentDelete(rctx, guestWithArchived.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, archivedChannel.Id)) + }() + + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + }) + require.NoError(t, rErr) + for _, report := range userReport { + if report.Username == guestWithArchived.Username { + require.NotNil(t, report.ChannelCount) + require.Equal(t, 1, *report.ChannelCount, "archived channel should not be counted") + } + } + + singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterSingleChannel, + }) + require.NoError(t, rErr) + found := false + for _, report := range singleReport { + if report.Username == guestWithArchived.Username { + found = true + } + } + require.True(t, found, "guest with one active channel and one archived channel should appear in single-channel filter") + + multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterMultipleChannel, + }) + require.NoError(t, rErr) + for _, report := range multiReport { + require.NotEqual(t, guestWithArchived.Username, report.Username, + "guest with one active channel and one archived channel should NOT appear in multi-channel filter") + } + }) + + t.Run("DM and GM channels should not count toward guest channel memberships", func(t *testing.T) { + guestWithDM := &model.User{Username: "zguest_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestWithDM, gErr = ss.User().Save(rctx, guestWithDM) + require.NoError(t, gErr) + + otherUser := &model.User{Username: "zother_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"} + otherUser, gErr = ss.User().Save(rctx, otherUser) + require.NoError(t, gErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel1.Id, + UserId: guestWithDM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + dmChannel := &model.Channel{ + Name: model.GetDMNameFromIds(guestWithDM.Id, otherUser.Id), + Type: model.ChannelTypeDirect, + } + dmMember1 := &model.ChannelMember{ + UserId: guestWithDM.Id, + ChannelId: dmChannel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + dmMember2 := &model.ChannelMember{ + UserId: otherUser.Id, + ChannelId: dmChannel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + dmChannel, dmErr := ss.Channel().SaveDirectChannel(rctx, dmChannel, dmMember1, dmMember2) + require.NoError(t, dmErr) + + defer func() { + require.NoError(t, ss.User().PermanentDelete(rctx, guestWithDM.Id)) + require.NoError(t, ss.User().PermanentDelete(rctx, otherUser.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, dmChannel.Id)) + }() + + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + }) + require.NoError(t, rErr) + for _, report := range userReport { + if report.Username == guestWithDM.Username { + require.NotNil(t, report.ChannelCount) + require.Equal(t, 1, *report.ChannelCount, "DM channel should not be counted") + } + } + + singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterSingleChannel, + }) + require.NoError(t, rErr) + found := false + for _, report := range singleReport { + if report.Username == guestWithDM.Username { + found = true + } + } + require.True(t, found, "guest with one team channel and one DM should appear in single-channel filter") + + multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterMultipleChannel, + }) + require.NoError(t, rErr) + for _, report := range multiReport { + require.NotEqual(t, guestWithDM.Username, report.Username, + "guest with one team channel and one DM should NOT appear in multi-channel filter") + } + }) + + t.Run("private channels should count but GM channels should not", func(t *testing.T) { + privateChannel, chErr := ss.Channel().Save(rctx, &model.Channel{ + TeamId: team.Id, + DisplayName: "Private Channel", + Name: "private_channel_" + model.NewId(), + Type: model.ChannelTypePrivate, + }, 100) + require.NoError(t, chErr) + + gmChannel, chErr := ss.Channel().Save(rctx, &model.Channel{ + DisplayName: "Group Message", + Name: "gm_channel_" + model.NewId(), + Type: model.ChannelTypeGroup, + }, -1) + require.NoError(t, chErr) + + guestPrivateGM := &model.User{Username: "zguest_privgm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"} + guestPrivateGM, gErr = ss.User().Save(rctx, guestPrivateGM) + require.NoError(t, gErr) + + gmOtherUser := &model.User{Username: "zother_gm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"} + gmOtherUser, gErr = ss.User().Save(rctx, gmOtherUser) + require.NoError(t, gErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: guestChannel1.Id, + UserId: guestPrivateGM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: privateChannel.Id, + UserId: guestPrivateGM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + + for _, uid := range []string{guestPrivateGM.Id, gmOtherUser.Id} { + _, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{ + ChannelId: gmChannel.Id, + UserId: uid, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.NoError(t, mErr) + } + + defer func() { + require.NoError(t, ss.User().PermanentDelete(rctx, guestPrivateGM.Id)) + require.NoError(t, ss.User().PermanentDelete(rctx, gmOtherUser.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, privateChannel.Id)) + require.NoError(t, ss.Channel().PermanentDelete(rctx, gmChannel.Id)) + }() + + userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + }) + require.NoError(t, rErr) + for _, report := range userReport { + if report.Username == guestPrivateGM.Username { + require.NotNil(t, report.ChannelCount) + require.Equal(t, 2, *report.ChannelCount, "should count open + private, not GM") + } + } + + multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterMultipleChannel, + }) + require.NoError(t, rErr) + found := false + for _, report := range multiReport { + if report.Username == guestPrivateGM.Username { + found = true + } + } + require.True(t, found, "guest with open + private channels should appear in multi-channel filter") + + singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 200, + }, + GuestFilter: model.GuestFilterSingleChannel, + }) + require.NoError(t, rErr) + for _, report := range singleReport { + require.NotEqual(t, guestPrivateGM.Username, report.Username, + "guest with 2 team channels should NOT appear in single-channel filter") + } + }) + + t.Run("guest filter count query should match", func(t *testing.T) { + allCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + }, + GuestFilter: model.GuestFilterAll, + }) + require.NoError(t, rErr) + require.Equal(t, int64(3), allCount) + + singleCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + }, + GuestFilter: model.GuestFilterSingleChannel, + }) + require.NoError(t, rErr) + require.Equal(t, int64(1), singleCount) + + multiCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + }, + GuestFilter: model.GuestFilterMultipleChannel, + }) + require.NoError(t, rErr) + require.Equal(t, int64(1), multiCount) + + // guestNoChannels has 0 active channels, so it appears in "all" but + // neither "single" nor "multi"; the sum is strictly less than allCount. + require.Equal(t, int64(2), singleCount+multiCount) + require.Less(t, singleCount+multiCount, allCount) + }) + }) } func testMfaUsedTimestamps(t *testing.T, rctx request.CTX, ss store.Store) { diff --git a/server/i18n/en.json b/server/i18n/en.json index 467dfd71edb..4c6e2950cb6 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -11656,6 +11656,10 @@ "id": "model.user_access_token.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.user_report_options.is_valid.invalid_guest_filter", + "translation": "Provided guest filter is not valid." + }, { "id": "model.user_report_options.is_valid.invalid_sort_column", "translation": "Provided sort column is not valid." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index cd38339a50b..f03e08bee01 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -1983,6 +1983,12 @@ func (c *Client4) GetUsersForReporting(ctx context.Context, options *UserReportO if options.DateRange != "" { values.Set("date_range", options.DateRange) } + if options.GuestFilter != "" { + values.Set("guest_filter", options.GuestFilter) + } + if options.SearchTerm != "" { + values.Set("search_term", options.SearchTerm) + } r, err := c.doAPIGetWithQuery(ctx, c.reportsRoute().Join("users"), values, "") if err != nil { diff --git a/server/public/model/report.go b/server/public/model/report.go index 8304a364fa7..0284c72e2ec 100644 --- a/server/public/model/report.go +++ b/server/public/model/report.go @@ -17,12 +17,18 @@ const ( ReportDurationLast6Months = "last_6_months" ReportingMaxPageSize = 100 + + GuestFilterAll = "all" + GuestFilterSingleChannel = "single_channel" + GuestFilterMultipleChannel = "multi_channel" ) var ( ReportExportFormats = []string{"csv"} UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"} + + AllowedGuestFilters = []string{GuestFilterAll, GuestFilterSingleChannel, GuestFilterMultipleChannel} ) type ReportableObject interface { @@ -76,11 +82,13 @@ func (options *ReportingBaseOptions) IsValid() *AppError { type UserReportQuery struct { User UserPostStats + ChannelCount *int } type UserReport struct { User UserPostStats + ChannelCount *int `json:"channel_count,omitempty"` } func (u *UserReport) ToReport() []string { @@ -100,6 +108,10 @@ func (u *UserReport) ToReport() []string { if u.TotalPosts != nil { totalPosts = strconv.Itoa(*u.TotalPosts) } + channelCount := "" + if u.ChannelCount != nil { + channelCount = strconv.Itoa(*u.ChannelCount) + } lastLogin := "" if u.LastLogin > 0 { lastLogin = time.UnixMilli(u.LastLogin).String() @@ -122,6 +134,7 @@ func (u *UserReport) ToReport() []string { lastPostDate, daysActive, totalPosts, + channelCount, deleteAt, } } @@ -134,6 +147,7 @@ type UserReportOptions struct { HideActive bool HideInactive bool SearchTerm string + GuestFilter string } func (u *UserReportOptions) IsValid() *AppError { @@ -146,6 +160,10 @@ func (u *UserReportOptions) IsValid() *AppError { return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_sort_column", nil, "", http.StatusBadRequest) } + if u.GuestFilter != "" && !slices.Contains(AllowedGuestFilters, u.GuestFilter) { + return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_guest_filter", nil, "", http.StatusBadRequest) + } + return nil } @@ -154,6 +172,7 @@ func (u *UserReportQuery) ToReport() *UserReport { return &UserReport{ User: u.User, UserPostStats: u.UserPostStats, + ChannelCount: u.ChannelCount, } } diff --git a/webapp/channels/src/components/admin_console/system_users/constants/index.ts b/webapp/channels/src/components/admin_console/system_users/constants/index.ts index 89db8bb4142..d6bf60126c9 100644 --- a/webapp/channels/src/components/admin_console/system_users/constants/index.ts +++ b/webapp/channels/src/components/admin_console/system_users/constants/index.ts @@ -11,6 +11,7 @@ export enum ColumnNames { lastPostDate = 'lastPostDateColumn', daysActive = 'daysActiveColumn', totalPosts = 'totalPostsColumn', + channelCount = 'channelCountColumn', actions = 'actionsColumn', } @@ -24,7 +25,9 @@ export enum RoleFilters { Any = 'any', Admin = 'system_admin', Member = 'system_user', - Guest = 'system_guest', + GuestAll = 'system_guest', + GuestSingleChannel = 'guest_single_channel', + GuestMultiChannel = 'guest_multi_channel', } export enum TeamFilters { diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.tsx b/webapp/channels/src/components/admin_console/system_users/system_users.tsx index 4510a025876..16c2f15acd5 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users.tsx @@ -383,6 +383,21 @@ function SystemUsers(props: Props) { enablePinning: false, enableSorting: false, }, + { + id: ColumnNames.channelCount, + accessorKey: 'channel_count', + header: formatMessage({ + id: 'admin.system_users.list.channelCount', + defaultMessage: 'Channel count', + }), + cell: (info: CellContext) => info.getValue() ?? null, + meta: { + isNumeric: true, + }, + enableHiding: true, + enablePinning: false, + enableSorting: false, + }, { id: ColumnNames.actions, accessorKey: 'actions', diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx index 89248e4cbc1..08c67415bd0 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx @@ -80,6 +80,13 @@ export function SystemUsersColumnTogglerMenu(props: Props) { defaultMessage='Messages posted' /> ); + case ColumnNames.channelCount: + return ( + + ); case ColumnNames.actions: return ( { - return [ - { - value: RoleFilters.Any, - label: formatMessage({ - id: 'admin.system_users.filters.role.any', - defaultMessage: 'Any', - }), - }, - { - value: RoleFilters.Admin, - label: formatMessage({ - id: 'admin.system_users.filters.role.system_admin', - defaultMessage: 'System Admin', - }), - }, - { - value: RoleFilters.Member, - label: formatMessage({ - id: 'admin.system_users.filters.role.system_user', - defaultMessage: 'Member', - }), - }, - { - value: RoleFilters.Guest, - label: formatMessage({ - id: 'admin.system_users.filters.role.system_guest', - defaultMessage: 'Guest', - }), - }, - ]; - }, []); - - const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, options)); + const anyOption: OptionType = useMemo(() => ({ + value: RoleFilters.Any, + label: formatMessage({ + id: 'admin.system_users.filters.role.any', + defaultMessage: 'Any', + }), + }), [formatMessage]); + + const roleOptions: OptionType[] = useMemo(() => [ + { + value: RoleFilters.Admin, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_admin', + defaultMessage: 'System Admin', + }), + }, + { + value: RoleFilters.Member, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_user', + defaultMessage: 'Member', + }), + }, + ], [formatMessage]); + + const guestOptions: OptionType[] = useMemo(() => [ + { + value: RoleFilters.GuestAll, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_guest', + defaultMessage: 'Guests (all)', + }), + }, + { + value: RoleFilters.GuestSingleChannel, + label: formatMessage({ + id: 'admin.system_users.filters.role.guest_single_channel', + defaultMessage: 'Guests in a single channel', + }), + }, + { + value: RoleFilters.GuestMultiChannel, + label: formatMessage({ + id: 'admin.system_users.filters.role.guest_multi_channel', + defaultMessage: 'Guests in multiple channels', + }), + }, + ], [formatMessage]); + + const flatOptions = useMemo(() => [anyOption, ...roleOptions, ...guestOptions], [anyOption, roleOptions, guestOptions]); + + const groupedOptions: Array> = useMemo(() => [ + {label: '', options: [anyOption]}, + {label: '', options: roleOptions}, + {label: '', options: guestOptions}, + ], [anyOption, roleOptions, guestOptions]); + + const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, flatOptions)); function handleChange(value: OptionType) { setValue(value); @@ -70,7 +95,7 @@ export function SystemUsersFilterRole(props: Props) { name='filterRole' isSearchable={false} legend={formatMessage({id: 'admin.system_users.filters.role.title', defaultMessage: 'Role'})} - options={options} + options={groupedOptions} value={value} onChange={handleChange} /> diff --git a/webapp/channels/src/components/admin_console/system_users/utils/index.test.ts b/webapp/channels/src/components/admin_console/system_users/utils/index.test.ts index 1c846202a5c..caf65f1ebb3 100644 --- a/webapp/channels/src/components/admin_console/system_users/utils/index.test.ts +++ b/webapp/channels/src/components/admin_console/system_users/utils/index.test.ts @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {GuestFilter} from '@mattermost/types/reports'; import type {UserReport} from '@mattermost/types/reports'; -import {ColumnNames, StatusFilter} from '../constants'; +import {ColumnNames, RoleFilters, StatusFilter} from '../constants'; -import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption} from './index'; +import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption, getRoleFilterOption, convertTableOptionsToUserReportOptions} from './index'; describe('getSortColumnForOptions', () => { it('should return correct sort column for email', () => { @@ -73,3 +74,72 @@ describe('getStatusFilterOption', () => { expect(result).toEqual({}); }); }); + +describe('getRoleFilterOption', () => { + it('should return undefined for both when role is Any', () => { + const result = getRoleFilterOption(RoleFilters.Any); + expect(result).toEqual({role_filter: undefined, guest_filter: undefined}); + }); + + it('should return undefined for both when role is not provided', () => { + const result = getRoleFilterOption(); + expect(result).toEqual({role_filter: undefined, guest_filter: undefined}); + }); + + it('should return role_filter for Admin', () => { + const result = getRoleFilterOption(RoleFilters.Admin); + expect(result).toEqual({role_filter: 'system_admin', guest_filter: undefined}); + }); + + it('should return role_filter for Member', () => { + const result = getRoleFilterOption(RoleFilters.Member); + expect(result).toEqual({role_filter: 'system_user', guest_filter: undefined}); + }); + + it('should return guest_filter all for GuestAll', () => { + const result = getRoleFilterOption(RoleFilters.GuestAll); + expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.All}); + }); + + it('should return guest_filter single_channel for GuestSingleChannel', () => { + const result = getRoleFilterOption(RoleFilters.GuestSingleChannel); + expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.SingleChannel}); + }); + + it('should return guest_filter multi_channel for GuestMultiChannel', () => { + const result = getRoleFilterOption(RoleFilters.GuestMultiChannel); + expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.MultipleChannel}); + }); +}); + +describe('convertTableOptionsToUserReportOptions', () => { + it('should set guest_filter and not role_filter when filterRole is GuestSingleChannel', () => { + const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestSingleChannel}); + expect(result.guest_filter).toBe(GuestFilter.SingleChannel); + expect(result.role_filter).toBeUndefined(); + }); + + it('should set guest_filter and not role_filter when filterRole is GuestMultiChannel', () => { + const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestMultiChannel}); + expect(result.guest_filter).toBe(GuestFilter.MultipleChannel); + expect(result.role_filter).toBeUndefined(); + }); + + it('should set guest_filter and not role_filter when filterRole is GuestAll', () => { + const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestAll}); + expect(result.guest_filter).toBe(GuestFilter.All); + expect(result.role_filter).toBeUndefined(); + }); + + it('should set role_filter and not guest_filter when filterRole is Admin', () => { + const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Admin}); + expect(result.role_filter).toBe('system_admin'); + expect(result.guest_filter).toBeUndefined(); + }); + + it('should not set role_filter or guest_filter when filterRole is Any', () => { + const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Any}); + expect(result.role_filter).toBeUndefined(); + expect(result.guest_filter).toBeUndefined(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_users/utils/index.tsx b/webapp/channels/src/components/admin_console/system_users/utils/index.tsx index 0ed1215f53e..cfa98024a58 100644 --- a/webapp/channels/src/components/admin_console/system_users/utils/index.tsx +++ b/webapp/channels/src/components/admin_console/system_users/utils/index.tsx @@ -5,7 +5,7 @@ import type {SortingState} from '@tanstack/react-table'; import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {UserReportSortColumns, ReportSortDirection} from '@mattermost/types/reports'; +import {UserReportSortColumns, ReportSortDirection, GuestFilter} from '@mattermost/types/reports'; import type {UserReportOptions, UserReport} from '@mattermost/types/reports'; import type {Team} from '@mattermost/types/teams'; @@ -186,11 +186,20 @@ export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: stri }; } -export function getRoleFilterOption(role?: string): Pick { +export function getRoleFilterOption(role?: string): Pick { if (!role || role === RoleFilters.Any) { - return {role_filter: undefined}; + return {role_filter: undefined, guest_filter: undefined}; } - return {role_filter: role}; + if (role === RoleFilters.GuestAll) { + return {role_filter: undefined, guest_filter: GuestFilter.All}; + } + if (role === RoleFilters.GuestSingleChannel) { + return {role_filter: undefined, guest_filter: GuestFilter.SingleChannel}; + } + if (role === RoleFilters.GuestMultiChannel) { + return {role_filter: undefined, guest_filter: GuestFilter.MultipleChannel}; + } + return {role_filter: role, guest_filter: undefined}; } export function getSearchFilterOption(search?: string): Pick { diff --git a/webapp/channels/src/components/dropdown_input.scss b/webapp/channels/src/components/dropdown_input.scss index 31c5ab31816..642d1ec9ab0 100644 --- a/webapp/channels/src/components/dropdown_input.scss +++ b/webapp/channels/src/components/dropdown_input.scss @@ -33,6 +33,10 @@ $dropdown_input_index: 999999; background-color: var(--center-channel-bg) !important; } +.DropDown__menu-list > .DropDown__group:first-child .DropDown__group-heading { + display: none; +} + .DropDown__single-value { color: var(--center-channel-color) !important; } diff --git a/webapp/channels/src/components/dropdown_input.tsx b/webapp/channels/src/components/dropdown_input.tsx index 91fb4291760..88febbf6325 100644 --- a/webapp/channels/src/components/dropdown_input.tsx +++ b/webapp/channels/src/components/dropdown_input.tsx @@ -56,6 +56,19 @@ const baseStyles = { ...provided, zIndex: 100, }), + group: (provided) => ({ + ...provided, + paddingTop: 0, + paddingBottom: 0, + }), + groupHeading: (provided) => ({ + ...provided, + height: 1, + margin: '4px 0', + padding: 0, + fontSize: 0, + backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)', + }), } satisfies StylesConfig; const IndicatorsContainer = (props: any) => { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b94b9a8e21c..5642a05b161 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3040,8 +3040,10 @@ "admin.system_users.exportButton.notLicensed.hint": "This feature is available on the professional plan", "admin.system_users.exportButton.notLicensed.title": "Professional feature", "admin.system_users.filters.role.any": "Any", + "admin.system_users.filters.role.guest_multi_channel": "Guests in multiple channels", + "admin.system_users.filters.role.guest_single_channel": "Guests in a single channel", "admin.system_users.filters.role.system_admin": "System Admin", - "admin.system_users.filters.role.system_guest": "Guest", + "admin.system_users.filters.role.system_guest": "Guests (all)", "admin.system_users.filters.role.system_user": "Member", "admin.system_users.filters.role.title": "Role", "admin.system_users.filters.status.active": "Activated users", @@ -3079,6 +3081,7 @@ "admin.system_users.list.actions.userGuest": "Guest", "admin.system_users.list.actions.userMember": "Member", "admin.system_users.list.caption": "System Users", + "admin.system_users.list.channelCount": "Channel count", "admin.system_users.list.daysActive": "Days active", "admin.system_users.list.email": "Email", "admin.system_users.list.lastActivity": "Last activity", diff --git a/webapp/channels/src/reducers/views/admin.ts b/webapp/channels/src/reducers/views/admin.ts index 028baffac40..5391fb29fde 100644 --- a/webapp/channels/src/reducers/views/admin.ts +++ b/webapp/channels/src/reducers/views/admin.ts @@ -66,7 +66,9 @@ export const adminConsoleUserManagementTablePropertiesInitialState: AdminConsole cursorDirection: CursorPaginationDirection.next, cursorUserId: '', cursorColumnValue: '', - columnVisibility: {}, + columnVisibility: { + channelCountColumn: false, + }, searchTerm: '', filterTeam: '', filterTeamLabel: '', diff --git a/webapp/platform/types/src/reports.ts b/webapp/platform/types/src/reports.ts index 52a46fb6ac8..fde2bebd58d 100644 --- a/webapp/platform/types/src/reports.ts +++ b/webapp/platform/types/src/reports.ts @@ -24,6 +24,12 @@ export enum ReportDuration { Last6Months = 'last_6_months', } +export enum GuestFilter { + All = 'all', + SingleChannel = 'single_channel', + MultipleChannel = 'multi_channel', +} + export enum CursorPaginationDirection { 'prev' = 'prev', 'next' = 'next', @@ -36,6 +42,7 @@ export type UserReportFilter = { hide_active?: boolean; hide_inactive?: boolean; search_term?: string; + guest_filter?: string; } export type UserReportOptions = UserReportFilter & { @@ -81,4 +88,5 @@ export type UserReport = UserProfile & { last_post_date?: number; days_active?: number; total_posts?: number; + channel_count?: number; } From c9a4092ac0a20351e3c2e0ac0cb593cc28b5bc0e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 12 Mar 2026 19:36:05 +0100 Subject: [PATCH 5/5] keeps plugin config on reenablement (#35545) * keeps plugin config on reenablement * fixes local config patch on plugin reenablement --- server/channels/api4/config.go | 5 +++ server/channels/api4/config_local.go | 5 +++ server/channels/api4/config_test.go | 66 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/server/channels/api4/config.go b/server/channels/api4/config.go index 046c49e4d27..c35ec4daa86 100644 --- a/server/channels/api4/config.go +++ b/server/channels/api4/config.go @@ -335,6 +335,11 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { c.App.HandleMessageExportConfig(cfg, appCfg) } + // Treating an empty plugins map as nil preserves the existing configs. + if len(cfg.PluginSettings.Plugins) == 0 { + cfg.PluginSettings.Plugins = nil + } + updatedCfg, err := config.Merge(appCfg, cfg, &utils.MergeConfig{ StructFieldFilter: filterFn, }) diff --git a/server/channels/api4/config_local.go b/server/channels/api4/config_local.go index 7909a049494..c6a64357fb8 100644 --- a/server/channels/api4/config_local.go +++ b/server/channels/api4/config_local.go @@ -116,6 +116,11 @@ func localPatchConfig(c *Context, w http.ResponseWriter, r *http.Request) { c.App.HandleMessageExportConfig(cfg, appCfg) } + // Treating an empty plugins map as nil preserves the existing configs. + if len(cfg.PluginSettings.Plugins) == 0 { + cfg.PluginSettings.Plugins = nil + } + updatedCfg, mergeErr := config.Merge(appCfg, cfg, &utils.MergeConfig{ StructFieldFilter: filterFn, }) diff --git a/server/channels/api4/config_test.go b/server/channels/api4/config_test.go index 1f0c158870a..921b378fead 100644 --- a/server/channels/api4/config_test.go +++ b/server/channels/api4/config_test.go @@ -951,6 +951,72 @@ func TestPatchConfig(t *testing.T) { _, _, err = th.SystemAdminClient.PatchConfig(context.Background(), &model.Config{}) require.NoError(t, err) }) + + t.Run("should preserve plugin configs when toggling plugin enable off then on", func(t *testing.T) { + // Have some plugin settings setup + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.Enable = model.NewPointer(true) + cfg.PluginSettings.Plugins = map[string]map[string]any{ + "com.example.oauth-plugin": { + "clientid": "test-client-id", + "clientsecret": "test-client-secret", + }, + } + }) + + // First PATCH: disable the plugin subsystem + disablePatch := &model.Config{} + disablePatch.PluginSettings.Enable = model.NewPointer(false) + disabledResponse, _, err := th.SystemAdminClient.PatchConfig(context.Background(), disablePatch) + require.NoError(t, err) + // The sanitized response returns an empty Plugins map when plugins are disabled + assert.Empty(t, disabledResponse.PluginSettings.Plugins) + + // Second PATCH: re-enable plugins using the response from the first PATCH + disabledResponse.PluginSettings.Enable = model.NewPointer(true) + _, _, err = th.SystemAdminClient.PatchConfig(context.Background(), &model.Config{ + PluginSettings: disabledResponse.PluginSettings, + }) + require.NoError(t, err) + + // Plugin configs must survive the round-trip unchanged + storedCfg := th.App.Config() + require.Contains(t, storedCfg.PluginSettings.Plugins, "com.example.oauth-plugin") + assert.Equal(t, "test-client-id", storedCfg.PluginSettings.Plugins["com.example.oauth-plugin"]["clientid"]) + }) + + t.Run("local client should preserve plugin configs when toggling plugin enable off then on", func(t *testing.T) { + // Have some plugin settings setup + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.Enable = model.NewPointer(true) + cfg.PluginSettings.Plugins = map[string]map[string]any{ + "com.example.oauth-plugin": { + "clientid": "test-client-id", + "clientsecret": "test-client-secret", + }, + } + }) + + // First PATCH: disable the plugin subsystem + disablePatch := &model.Config{} + disablePatch.PluginSettings.Enable = model.NewPointer(false) + disabledResponse, _, err := th.LocalClient.PatchConfig(context.Background(), disablePatch) + require.NoError(t, err) + // The sanitized response returns an empty Plugins map when plugins are disabled + assert.Empty(t, disabledResponse.PluginSettings.Plugins) + + // Second PATCH: re-enable plugins using the response from the first PATCH + disabledResponse.PluginSettings.Enable = model.NewPointer(true) + _, _, err = th.LocalClient.PatchConfig(context.Background(), &model.Config{ + PluginSettings: disabledResponse.PluginSettings, + }) + require.NoError(t, err) + + // Plugin configs must survive the round-trip unchanged + storedCfg := th.App.Config() + require.Contains(t, storedCfg.PluginSettings.Plugins, "com.example.oauth-plugin") + assert.Equal(t, "test-client-id", storedCfg.PluginSettings.Plugins["com.example.oauth-plugin"]["clientid"]) + }) } func TestMigrateConfig(t *testing.T) {