diff --git a/docs/main/administration-guide/configure/environment-configuration-settings.mdx b/docs/main/administration-guide/configure/environment-configuration-settings.mdx index 6a5ce0347d88..156dddbd6628 100644 --- a/docs/main/administration-guide/configure/environment-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/environment-configuration-settings.mdx @@ -306,6 +306,35 @@ Enabling this feature makes these connections susceptible to man-in-the-middle a +### Enable hardened mode + + ++++ + + + + + + +

Configure Mattermost to run in a hardened mode that makes user experience trade-offs in the interest of security.

  • true: Hardened mode is enabled.
  • false: (Default) Hardened mode is disabled.
  • System Config path: Environment > Web Server
  • config.json setting: ServiceSettings > EnableHardenedMode > false
  • Environment variable: MM_SERVICESETTINGS_ENABLEHARDENEDMODE
+ + + +This setting was called `ServiceSettings.ExperimentalEnableHardenedMode` until Mattermost v11.11. On first start after upgrading, the server automatically migrates the old key to `ServiceSettings.EnableHardenedMode` and removes it from `config.json`. + +Changes made when hardened mode is enabled: + +- Failed login returns a generic error message instead of a specific message for username and password. +- If [multi-factor authentication (MFA)](/administration-guide/onboard/multi-factor-authentication) is enabled, the route to check if a user has MFA enabled always returns true. This causes the MFA input screen to appear even if the user does not have MFA enabled. The user may enter any value to pass the screen. Note that hardened mode does not affect user experience when MFA is enforced. +- Password reset does not inform the user that they can not reset their SSO account through Mattermost and instead claims to have sent the password reset email. +- Mattermost sanitizes all 500 errors before returned to the client. Use the supplied `request_id` to match user-facing errors with the server logs. +- Standard users authenticated via username and password can't use post props reserved for integrations, such as `override_username` or `override_icon_url`. + + + ### Managed resource paths diff --git a/docs/main/administration-guide/configure/experimental-configuration-settings.mdx b/docs/main/administration-guide/configure/experimental-configuration-settings.mdx index 2113e32eb81c..a22429aa8f17 100644 --- a/docs/main/administration-guide/configure/experimental-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/experimental-configuration-settings.mdx @@ -101,23 +101,6 @@ Specify the maximum frequency, in seconds, which the batching job checks for new
-### Enable account deactivation - -**True**: Ability for users to deactivate their own account from **Settings \> Advanced \> Deactivate Account**. If a user deactivates their own account, they will get an email notification confirming they were deactivated. Available only when authentication is set to use email/password. Not available when authentication uses SAML or AD/LDAP. - -**False**: Ability for users to deactivate their own account is disabled. - - --- - - - - - -
This feature's config.json setting is "EnableUserDeactivation": false with options true and false.
- ### Enable automatic replies **True**: Users can enable Automatic Replies in **Settings \> Notifications**. Users set a custom message that will be automatically sent in response to Direct Messages. @@ -154,31 +137,6 @@ This setting determines whether team leave/join system messages are posted in th -### Enable hardened mode - -**True**: Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. - -**False**: Disables hardened mode. - -Changes made when hardened mode is enabled: - -- Failed login returns a generic error message instead of a specific message for username and password. -- If [multi-factor authentication (MFA)](/administration-guide/onboard/multi-factor-authentication) is enabled, the route to check if a user has MFA enabled always returns true. This causes the MFA input screen to appear even if the user does not have MFA enabled. The user may enter any value to pass the screen. Note that hardened mode does not affect user experience when MFA is enforced. -- Password reset does not inform the user that they can not reset their SSO account through Mattermost and instead claims to have sent the password reset email. -- Mattermost sanitizes all 500 errors before returned to the client. Use the supplied `request_id` to match user facing errors with the server logs. -- Standard users authenticated via username and password can't use post props reserved for integrations, such as `override_username` or `override_icon_url`. - - --- - - - - - -
This feature's config.json setting is "ExperimentalEnableHardenedMode": false with options true and false.
- ### Enable Mobile Watermark Available on [Enterprise Advanced](https://mattermost.com/pricing/) from Mattermost v11.7 onward. @@ -261,21 +219,6 @@ If the team URL of the primary team is `https://example.mattermost.com/myteam/`, -### User status away timeout - -This setting defines the number of seconds after which the user's status indicator changes to "Away", when they are away from Mattermost. - - --- - - - - - -
This feature's config.json setting is "UserStatusAwayTimeout": 300 with numerical input.
- ### Disable data refetching on browser refocus This setting disables re-fetching of channel and channel members on browser focus. diff --git a/docs/main/administration-guide/configure/site-configuration-settings.mdx b/docs/main/administration-guide/configure/site-configuration-settings.mdx index bccb14bb7f3f..e576861be1ca 100644 --- a/docs/main/administration-guide/configure/site-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/site-configuration-settings.mdx @@ -973,6 +973,21 @@ Access the following configuration settings in the System Console by going to ** +### User status away timeout + + ++++ + + + + + + +

The number of seconds of inactivity after which a user's status indicator changes to Away.

Numerical input in seconds. Default is 300 (5 minutes).

  • System Config path: Site Configuration > Users and Teams
  • config.json setting: TeamSettings > UserStatusAwayTimeout > 300
  • Environment variable: MM_TEAMSETTINGS_USERSTATUSAWAYTIMEOUT
+ ### Enable custom user groups @@ -1003,6 +1018,21 @@ Access the following configuration settings in the System Console by going to **
+### Enable account deactivation + + ++++ + + + + + + +
  • true: Users can deactivate their own account from Settings > Advanced > Deactivate Account. When a user deactivates their own account, they receive an email notification confirming the deactivation.
  • false: (Default) Users can't deactivate their own account.

Available only when authentication is set to use email and password. Not available when authentication uses SAML or AD/LDAP.

  • System Config path: Site Configuration > Users and Teams
  • config.json setting: TeamSettings > EnableUserDeactivation > false
  • Environment variable: MM_TEAMSETTINGS_ENABLEUSERDEACTIVATION
+ ### Channel category sorting From Mattermost v11.8, channel category sorting is enabled by default. When enabled, channel admins can choose a default sidebar category when creating or editing a channel. Channel admins can select an existing category, type a new category name, or clear the default category from channel settings. Members who join the channel see it under that category in their sidebar. When disabled, the default category selector is hidden. diff --git a/docs/main/end-user-guide/preferences/manage-advanced-options.mdx b/docs/main/end-user-guide/preferences/manage-advanced-options.mdx index 36830458296c..b416cfb0e5b0 100644 --- a/docs/main/end-user-guide/preferences/manage-advanced-options.mdx +++ b/docs/main/end-user-guide/preferences/manage-advanced-options.mdx @@ -73,7 +73,7 @@ This option isn't something you can set using the mobile app. ## Deactivate account -You can deactivate your account if you access Mattermost using an email address and password, and when your system admin has [enabled your ability to do so](/administration-guide/configure/experimental-configuration-settings#enable-account-deactivation). Deactivating your account removes your ability to access Mattermost, and disables all email and mobile notifications. +You can deactivate your account if you access Mattermost using an email address and password, and when your system admin has [enabled your ability to do so](/administration-guide/configure/site-configuration-settings#enable-account-deactivation). Deactivating your account removes your ability to access Mattermost, and disables all email and mobile notifications. diff --git a/e2e-tests/cypress/tests/support/api/cloud_default_config.json b/e2e-tests/cypress/tests/support/api/cloud_default_config.json index 71d21011530c..29d64f13be02 100644 --- a/e2e-tests/cypress/tests/support/api/cloud_default_config.json +++ b/e2e-tests/cypress/tests/support/api/cloud_default_config.json @@ -78,7 +78,7 @@ "EnableAPITeamDeletion": true, "EnableAPITriggerAdminNotifications": false, "EnableAPIUserDeletion": false, - "ExperimentalEnableHardenedMode": false, + "EnableHardenedMode": false, "ExperimentalStrictCSRFEnforcement": false, "EnableEmailInvitations": true, "DisableBotsWhenOwnerIsDeactivated": true, diff --git a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json index 019ebf99cd3b..e5ca5d658e28 100644 --- a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json +++ b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json @@ -80,7 +80,7 @@ "EnableAPITeamDeletion": true, "EnableAPITriggerAdminNotifications": false, "EnableAPIUserDeletion": false, - "ExperimentalEnableHardenedMode": false, + "EnableHardenedMode": false, "ExperimentalStrictCSRFEnforcement": false, "EnableEmailInvitations": true, "DisableBotsWhenOwnerIsDeactivated": true, diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 5cbe5204da22..b80e56484884 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -190,7 +190,7 @@ const defaultServerConfig: AdminConfig = { EnableAPIPostDeletion: false, EnableDesktopLandingPage: true, MinimumDesktopAppVersion: '', - ExperimentalEnableHardenedMode: false, + EnableHardenedMode: false, ExperimentalStrictCSRFEnforcement: false, EnableEmailInvitations: false, DisableBotsWhenOwnerIsDeactivated: true, diff --git a/e2e-tests/playwright/specs/functional/channels/drafts/draft_channel_switch.spec.ts b/e2e-tests/playwright/specs/functional/channels/drafts/draft_channel_switch.spec.ts new file mode 100644 index 000000000000..2fad235305eb --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/drafts/draft_channel_switch.spec.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +test.describe('draft channel switch', () => { + /** + * @objective Verify a typed draft on one channel persists, restores after + * switching away and back, and posts only to the origin channel. + */ + test('typed draft stays on the origin channel after switching away and back', {tag: '@messaging'}, async ({pw}) => { + const {team, user} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + const originDraft = `origin-draft-${pw.random.id()}`; + const destinationMessage = `town-square-${pw.random.id()}`; + + // # Type a draft in Off-Topic and do not send it + await channelsPage.centerView.postCreate.writeMessage(originDraft); + + // # Switch to Town Square via the sidebar + await channelsPage.sidebarLeft.goToItem('town-square'); + await channelsPage.centerView.header.toHaveTitle('Town Square'); + + // * Destination composer must not inherit the origin draft + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(''); + + // * Origin draft was persisted: the channel pencil is in the DOM + // (often CSS-hidden until hover) and the Drafts sidebar link appears + await expect(channelsPage.sidebarLeft.item('off-topic').getByTestId('draftIcon')).toHaveCount(1); + await channelsPage.sidebarLeft.draftsVisible(); + + // # Send a different message from Town Square + await channelsPage.centerView.postCreate.writeMessage(destinationMessage); + await channelsPage.centerView.postCreate.sendMessage(); + + // * Town Square shows the destination message + await channelsPage.centerView.waitUntilLastPostContains(destinationMessage); + + // # Return to Off-Topic + await channelsPage.sidebarLeft.goToItem('off-topic'); + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + + // * Origin draft is still in the composer + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(originDraft); + + // # Send the restored draft + await channelsPage.centerView.postCreate.sendMessage(); + + // * Off-Topic shows the origin draft message + await channelsPage.centerView.waitUntilLastPostContains(originDraft); + + // # Return to Town Square + await channelsPage.sidebarLeft.goToItem('town-square'); + await channelsPage.centerView.header.toHaveTitle('Town Square'); + + // * Origin draft did not post to Town Square + await expect(channelsPage.centerView.container).not.toContainText(originDraft); + }); + + /** + * @objective Verify Ctrl/Cmd+K restores the destination draft and routes + * messages to the selected channel with concurrent React enabled. + */ + test( + 'quick switcher keeps drafts and messages scoped to their channels with concurrent React', + {tag: '@messaging'}, + async ({pw}) => { + await pw.ensureFeatureFlag('EnableConcurrentReact', true); + + const {team, user} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + const originDraft = `quick-switch-origin-${pw.random.id()}`; + const destinationMessage = `quick-switch-destination-${pw.random.id()}`; + + // # Leave a draft in Off-Topic + await channelsPage.centerView.postCreate.writeMessage(originDraft); + + // # Switch to Town Square using Ctrl/Cmd+K + await page.keyboard.press('ControlOrMeta+K'); + await expect(channelsPage.findChannelsModal.input).toBeVisible(); + await channelsPage.findChannelsModal.input.fill('town'); + await channelsPage.findChannelsModal.selectChannel('town-square'); + await channelsPage.centerView.header.toHaveTitle('Town Square'); + + // * Town Square did not inherit the Off-Topic draft + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(''); + + // # Send a destination-owned message + await channelsPage.centerView.postCreate.writeMessage(destinationMessage); + await channelsPage.centerView.postCreate.sendMessage(); + await channelsPage.centerView.waitUntilLastPostContains(destinationMessage); + + // # Return to Off-Topic using Ctrl/Cmd+K + await page.keyboard.press('ControlOrMeta+K'); + await expect(channelsPage.findChannelsModal.input).toBeVisible(); + await channelsPage.findChannelsModal.input.fill('off'); + await channelsPage.findChannelsModal.selectChannel('off-topic'); + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + + // * The origin draft was restored and the destination message was not misrouted + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(originDraft); + await expect(channelsPage.centerView.container).not.toContainText(destinationMessage); + }, + ); + + /** + * @objective Verify sending /msg to an existing DM clears the origin + * channel draft instead of leaving it behind for later restoration. + * + * @precondition + * The DM channel already exists so the redirect uses the fast path with no + * createDirectChannel round trip. + */ + test( + 'sending /msg to an existing DM clears the origin draft instead of restoring it', + {tag: '@slash_commands'}, + async ({pw}) => { + const {adminClient, userClient, team, user} = await pw.initSetup(); + const [target] = await adminClient.createUsers(team.id, 1, 'draft-msg'); + + await userClient.createDirectChannel([user.id, target.id]); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // Trailing space dismisses the @mention autocomplete. + await channelsPage.centerView.postCreate.writeMessage(`/msg @${target.username} `); + await channelsPage.centerView.postCreate.sendMessage(); + + await channelsPage.centerView.header.toHaveTitle(target.username); + await expect(page).toHaveURL(new RegExp(`/${team.name}/messages/@${target.username}`)); + + // * Destination composer is empty — it did not adopt the /msg text + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(''); + + // # Return to Off-Topic + await channelsPage.sidebarLeft.goToItem('off-topic'); + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + + // * Origin draft was cleared by the submit, not left behind as /msg + expect(await channelsPage.centerView.postCreate.getInputValue()).toBe(''); + await expect(channelsPage.sidebarLeft.item('off-topic').getByTestId('draftIcon')).toHaveCount(0); + }, + ); + + /** + * @objective Verify a message typed after a settled /msg redirect to an + * existing DM posts to the DM, not the origin channel. + * + * @precondition + * The DM channel already exists so the redirect uses the fast path with no + * createDirectChannel round trip. + */ + test( + 'posts a later message to the DM rather than the origin channel after /msg', + {tag: '@slash_commands'}, + async ({pw}) => { + const {adminClient, userClient, team, user} = await pw.initSetup(); + const [target] = await adminClient.createUsers(team.id, 1, 'stale-dm'); + + const dmChannel = await userClient.createDirectChannel([user.id, target.id]); + await userClient.createPost({ + channel_id: dmChannel.id, + message: 'seeding the existing DM', + } as Parameters[0]); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // Trailing space dismisses the @mention autocomplete. + await channelsPage.centerView.postCreate.writeMessage(`/msg @${target.username} `); + await channelsPage.centerView.postCreate.sendMessage(); + + await channelsPage.centerView.header.toHaveTitle(target.username); + await expect(page).toHaveURL(new RegExp(`/${team.name}/messages/@${target.username}`)); + + const message = `stale-draft-${pw.random.id()}`; + await channelsPage.centerView.postCreate.writeMessage(message); + await channelsPage.centerView.postCreate.sendMessage(); + + // * Follow-up message appears in the DM + await channelsPage.centerView.waitUntilLastPostContains(message); + + // # Return to Off-Topic + await channelsPage.sidebarLeft.goToItem('off-topic'); + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + + // * Follow-up message did not post to the origin channel + await expect(channelsPage.centerView.container).not.toContainText(message); + }, + ); +}); diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 3684943f9f60..af7b4bbe2012 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -283,13 +283,13 @@ func TestCreatePost(t *testing.T) { }) t.Run("err with integrations-reserved props", func(t *testing.T) { - originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode + originalHardenedModeSetting := *th.App.Config().ServiceSettings.EnableHardenedMode th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = true + *cfg.ServiceSettings.EnableHardenedMode = true }) defer th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = originalHardenedModeSetting + *cfg.ServiceSettings.EnableHardenedMode = originalHardenedModeSetting }) rpost, postResp, postErr := client.CreatePost(context.Background(), &model.Post{ @@ -712,13 +712,13 @@ func TestCreatePostWithOAuthClient(t *testing.T) { assert.Contains(t, post.GetProps(), model.PostPropsFromOAuthApp, fmt.Sprintf("missing %s prop when using OAuth client", model.PostPropsOverrideUsername)) t.Run("allow username and icon overrides", func(t *testing.T) { - originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode + originalHardenedModeSetting := *th.App.Config().ServiceSettings.EnableHardenedMode th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = true + *cfg.ServiceSettings.EnableHardenedMode = true }) defer th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = originalHardenedModeSetting + *cfg.ServiceSettings.EnableHardenedMode = originalHardenedModeSetting }) post, _, err = client.CreatePost(context.Background(), &model.Post{ @@ -1976,13 +1976,13 @@ func TestUpdatePost(t *testing.T) { }) t.Run("err with integrations-reserved props", func(t *testing.T) { - originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode + originalHardenedModeSetting := *th.App.Config().ServiceSettings.EnableHardenedMode th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = true + *cfg.ServiceSettings.EnableHardenedMode = true }) defer th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = originalHardenedModeSetting + *cfg.ServiceSettings.EnableHardenedMode = originalHardenedModeSetting }) _, resp, err := client.UpdatePost(context.Background(), rpost.Id, &model.Post{ @@ -2917,13 +2917,13 @@ func TestPatchPost(t *testing.T) { }) t.Run("err with integrations-reserved props", func(t *testing.T) { - originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode + originalHardenedModeSetting := *th.App.Config().ServiceSettings.EnableHardenedMode th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = true + *cfg.ServiceSettings.EnableHardenedMode = true }) defer th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExperimentalEnableHardenedMode = originalHardenedModeSetting + *cfg.ServiceSettings.EnableHardenedMode = originalHardenedModeSetting }) post := &model.Post{ diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index cf86c65624b7..4800e96f9bd2 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -2107,7 +2107,7 @@ func sendPasswordReset(c *Context, w http.ResponseWriter, r *http.Request) { sent, err := c.App.SendPasswordReset(c.AppContext, email, c.App.GetSiteURL()) if err != nil { - if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode { + if *c.App.Config().ServiceSettings.EnableHardenedMode { ReturnStatusOK(w) } else { c.Err = err diff --git a/server/channels/app/post_permission_utils.go b/server/channels/app/post_permission_utils.go index c059ebc994fd..dda89f81117a 100644 --- a/server/channels/app/post_permission_utils.go +++ b/server/channels/app/post_permission_utils.go @@ -83,7 +83,7 @@ func postPriorityCheck( } func PostHardenedModeCheckWithApp(a *App, isIntegration bool, props model.StringInterface) *model.AppError { - hardenedModeEnabled := *a.Config().ServiceSettings.ExperimentalEnableHardenedMode + hardenedModeEnabled := *a.Config().ServiceSettings.EnableHardenedMode return postHardenedModeCheck(hardenedModeEnabled, isIntegration, props) } diff --git a/server/channels/web/handlers.go b/server/channels/web/handlers.go index 99fecc524151..25ce33079507 100644 --- a/server/channels/web/handlers.go +++ b/server/channels/web/handlers.go @@ -438,7 +438,7 @@ func (h Handler) handleContextError(c *Context, w http.ResponseWriter, r *http.R } // Sanitize all 5xx error messages in hardened mode - if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode && c.Err.StatusCode >= 500 { + if *c.App.Config().ServiceSettings.EnableHardenedMode && c.Err.StatusCode >= 500 { c.Err.Id = "" c.Err.Message = "Internal Server Error" c.Err.WipeDetailed() diff --git a/server/public/model/config.go b/server/public/model/config.go index cbf8cbf3d72f..e3a9bed0a532 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -485,7 +485,8 @@ type ServiceSettings struct { EnableAPIPostDeletion *bool EnableDesktopLandingPage *bool MinimumDesktopAppVersion *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` - ExperimentalEnableHardenedMode *bool `access:"experimental_features"` + EnableHardenedMode *bool `access:"environment_web_server"` + ExperimentalEnableHardenedMode *bool `json:",omitempty"` // Deprecated: use `EnableHardenedMode`. ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"` EnableEmailInvitations *bool `access:"authentication_signup"` DisableBotsWhenOwnerIsDeactivated *bool `access:"integrations_bot_accounts"` @@ -935,6 +936,9 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.ExperimentalEnableHardenedMode == nil { s.ExperimentalEnableHardenedMode = new(false) } + if s.EnableHardenedMode == nil { + s.EnableHardenedMode = new(*s.ExperimentalEnableHardenedMode) + } if s.ExperimentalStrictCSRFEnforcement == nil { s.ExperimentalStrictCSRFEnforcement = new(false) @@ -2536,7 +2540,7 @@ type TeamSettings struct { EnableJoinLeaveMessageByDefault *bool `access:"site_users_and_teams"` EnableUserCreation *bool `access:"authentication_signup"` EnableOpenServer *bool `access:"authentication_signup"` - EnableUserDeactivation *bool `access:"experimental_features"` + EnableUserDeactivation *bool `access:"site_users_and_teams"` RestrictCreationToDomains *string `access:"authentication_signup"` // telemetry: none EnableCustomUserStatuses *bool `access:"site_users_and_teams"` EnableCustomBrand *bool `access:"site_customization"` @@ -2545,7 +2549,7 @@ type TeamSettings struct { RestrictDirectMessage *string `access:"site_users_and_teams"` EnableLastActiveTime *bool `access:"site_users_and_teams"` // In seconds. - UserStatusAwayTimeout *int64 `access:"experimental_features"` + UserStatusAwayTimeout *int64 `access:"site_users_and_teams"` MaxChannelsPerTeam *int64 `access:"site_users_and_teams"` EnableChannelCategorySorting *bool `access:"site_users_and_teams"` MaxNotificationsPerChannel *int64 `access:"environment_push_notification_server"` diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 930b190e7757..387e7bd06e81 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -270,6 +270,45 @@ func TestServiceSettingsIsValid(t *testing.T) { } } +func TestServiceSettingsHardenedModeMigration(t *testing.T) { + t.Run("defaults to disabled without the deprecated setting", func(t *testing.T) { + ss := ServiceSettings{} + ss.SetDefaults(false) + + require.False(t, *ss.EnableHardenedMode) + require.False(t, *ss.ExperimentalEnableHardenedMode) + assert.Nil(t, ss.isValid()) + }) + + t.Run("accepts the renamed setting", func(t *testing.T) { + ss := ServiceSettings{EnableHardenedMode: new(true)} + ss.SetDefaults(false) + + assert.Nil(t, ss.isValid()) + }) + + t.Run("migrates the deprecated setting to the renamed setting", func(t *testing.T) { + ss := ServiceSettings{ExperimentalEnableHardenedMode: new(true)} + ss.SetDefaults(false) + + require.True(t, *ss.EnableHardenedMode) + require.True(t, *ss.ExperimentalEnableHardenedMode) + assert.Nil(t, ss.isValid()) + }) + + t.Run("explicit new key wins over deprecated key", func(t *testing.T) { + ss := ServiceSettings{ + EnableHardenedMode: new(false), + ExperimentalEnableHardenedMode: new(true), + } + ss.SetDefaults(false) + + require.False(t, *ss.EnableHardenedMode) + require.True(t, *ss.ExperimentalEnableHardenedMode) + assert.Nil(t, ss.isValid()) + }) +} + func TestConfigEnableDeveloper(t *testing.T) { testCases := []struct { Description string diff --git a/server/tests/test-config.json b/server/tests/test-config.json index c262740fc9d0..42750ac36c5e 100644 --- a/server/tests/test-config.json +++ b/server/tests/test-config.json @@ -62,7 +62,7 @@ "ExperimentalGroupUnreadChannels": "disabled", "EnableAPITeamDeletion": false, "EnableAPITriggerAdminNotifications": false, - "ExperimentalEnableHardenedMode": false + "EnableHardenedMode": false }, "TeamSettings": { "SiteName": "Mattermost", diff --git a/webapp/channels/src/actions/views/create_comment.test.jsx b/webapp/channels/src/actions/views/create_comment.test.jsx index 948b4ff475bb..12d680015482 100644 --- a/webapp/channels/src/actions/views/create_comment.test.jsx +++ b/webapp/channels/src/actions/views/create_comment.test.jsx @@ -279,7 +279,7 @@ describe('rhs view actions', () => { }; test('it adds message into history', () => { - store.dispatch(onSubmit(draft, {})); + store.dispatch(onSubmit(channelId, rootId, draft, {})); const testStore = mockStore(initialState); testStore.dispatch(addMessageIntoHistory('test')); @@ -290,7 +290,7 @@ describe('rhs view actions', () => { }); test('it submits a command when message is /away', () => { - store.dispatch(onSubmit({ + store.dispatch(onSubmit(channelId, rootId, { message: '/away', fileInfos: [], uploadsInProgress: [], @@ -307,7 +307,7 @@ describe('rhs view actions', () => { }); test('it submits a regular post when options.ignoreSlash is true', () => { - store.dispatch(onSubmit({ + store.dispatch(onSubmit(channelId, rootId, { message: '/fakecommand', fileInfos: [], uploadsInProgress: [], @@ -323,7 +323,7 @@ describe('rhs view actions', () => { }); test('it submits a regular post when message is something else', () => { - store.dispatch(onSubmit({ + store.dispatch(onSubmit(channelId, rootId, { message: 'test msg', fileInfos: [], uploadsInProgress: [], diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index 5c917b80972c..de4131ad6ce0 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -175,12 +175,14 @@ export type OnSubmitOptions = { }; export function onSubmit( + channelId: string, + rootId: string, draft: PostDraft, options: OnSubmitOptions, schedulingInfo?: SchedulingInfo, ): ActionFuncAsync { return async (dispatch, getState) => { - const {message, channelId, rootId} = draft; + const {message} = draft; const state = getState(); dispatch(addMessageIntoHistory(message)); diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index c4e04a490862..f8d311fd9de8 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -1161,6 +1161,24 @@ const AdminDefinition: AdminDefinitionType = { }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), }, + { + type: 'bool', + key: 'ServiceSettings.EnableHardenedMode', + label: defineMessage({id: 'admin.service.hardenedModeTitle', defaultMessage: 'Enable Hardened Mode:'}), + help_text: defineMessage({id: 'admin.service.hardenedModeDesc', defaultMessage: 'Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided + help_text_values: { + link: (msg: string) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), + }, { type: 'text', key: 'ServiceSettings.ManagedResourcePaths', @@ -3084,6 +3102,14 @@ const AdminDefinition: AdminDefinitionType = { help_text: defineMessage({id: 'admin.team.lastActiveTimeDescription', defaultMessage: 'When enabled, last active time allows users to see when someone was last online.'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), }, + { + type: 'number', + key: 'TeamSettings.UserStatusAwayTimeout', + label: defineMessage({id: 'admin.team.userStatusAwayTimeoutTitle', defaultMessage: 'User Status Away Timeout (seconds):'}), + help_text: defineMessage({id: 'admin.team.userStatusAwayTimeoutDescription', defaultMessage: 'This setting defines the number of seconds of inactivity after which the user\'s status indicator changes to "Away".'}), + placeholder: defineMessage({id: 'admin.team.userStatusAwayTimeoutExample', defaultMessage: 'E.g.: "300"'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), + }, { type: 'bool', key: 'ServiceSettings.EnableCustomGroups', @@ -3102,6 +3128,14 @@ const AdminDefinition: AdminDefinitionType = { placeholder: defineMessage({id: 'admin.team.refreshPostStatsRunTimeExample', defaultMessage: 'E.g.: "00:00"'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), }, + { + type: 'bool', + key: 'TeamSettings.EnableUserDeactivation', + label: defineMessage({id: 'admin.team.enableUserDeactivationTitle', defaultMessage: 'Enable Account Deactivation:'}), + help_text: defineMessage({id: 'admin.team.enableUserDeactivationDescription', defaultMessage: 'When true, users may deactivate their own account from **Settings > Advanced**. If a user deactivates their own account, they will get an email notification confirming they were deactivated. When false, users may not deactivate their own account. Only available to users who sign in with an email address and password.'}), + help_text_markdown: true, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)), + }, { type: 'text', key: 'ServiceSettings.DeleteAccountLink', @@ -6650,14 +6684,6 @@ const AdminDefinition: AdminDefinitionType = { placeholder: defineMessage({id: 'admin.experimental.emailBatchingInterval.example', defaultMessage: 'E.g.: "30"'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), }, - { - type: 'bool', - key: 'TeamSettings.EnableUserDeactivation', - label: defineMessage({id: 'admin.experimental.enableUserDeactivation.title', defaultMessage: 'Enable Account Deactivation:'}), - help_text: defineMessage({id: 'admin.experimental.enableUserDeactivation.desc', defaultMessage: 'When true, users may deactivate their own account from **Settings > Advanced**. If a user deactivates their own account, they will get an email notification confirming they were deactivated. When false, users may not deactivate their own account.'}), - help_text_markdown: true, - isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), - }, { type: 'bool', key: 'TeamSettings.ExperimentalEnableAutomaticReplies', @@ -6674,24 +6700,6 @@ const AdminDefinition: AdminDefinitionType = { help_text_markdown: false, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), }, - { - type: 'bool', - key: 'ServiceSettings.ExperimentalEnableHardenedMode', - label: defineMessage({id: 'admin.experimental.experimentalEnableHardenedMode.title', defaultMessage: 'Enable Hardened Mode:'}), - help_text: defineMessage({id: 'admin.experimental.experimentalEnableHardenedMode.desc', defaultMessage: 'Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided - help_text_values: { - link: (msg: string) => ( - - {msg} - - ), - }, - help_text_markdown: false, - isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), - }, { type: 'number', key: 'ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds', @@ -6718,15 +6726,6 @@ const AdminDefinition: AdminDefinitionType = { help_text_markdown: false, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), }, - { - type: 'number', - key: 'TeamSettings.UserStatusAwayTimeout', - label: defineMessage({id: 'admin.experimental.userStatusAwayTimeout.title', defaultMessage: 'User Status Away Timeout:'}), - help_text: defineMessage({id: 'admin.experimental.userStatusAwayTimeout.desc', defaultMessage: 'This setting defines the number of seconds after which the user\'s status indicator changes to "Away", when they are away from Mattermost.'}), - help_text_markdown: false, - placeholder: defineMessage({id: 'admin.experimental.userStatusAwayTimeout.example', defaultMessage: 'E.g.: "300"'}), - isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), - }, { type: 'bool', key: 'ExperimentalSettings.DisableAppBar', diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx index 3258e1e015e1..cba4e23d08ff 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -3,16 +3,20 @@ import React from 'react'; +import type {PostType} from '@mattermost/types/posts'; +import {PostPriority} from '@mattermost/types/posts'; + import Permissions from 'mattermost-redux/constants/permissions'; +import {onSubmit} from 'actions/views/create_comment'; import {removeDraft, updateDraft} from 'actions/views/drafts'; import type {FileUpload} from 'components/file_upload/file_upload'; import type Textbox from 'components/textbox/textbox'; import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; -import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils'; -import Constants, {Locations, StoragePrefixes} from 'utils/constants'; +import {renderWithContext, userEvent, screen, act, fireEvent} from 'tests/react_testing_utils'; +import Constants, {Locations, PostTypes, StoragePrefixes} from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; import type {PostDraft} from 'types/store/draft'; @@ -26,6 +30,11 @@ jest.mock('actions/views/drafts', () => ({ removeDraft: jest.fn((...args) => ({type: 'MOCK_REMOVE_DRAFT', args})), })); +jest.mock('actions/views/create_comment', () => ({ + ...jest.requireActual('actions/views/create_comment'), + onSubmit: jest.fn(() => () => Promise.resolve({data: true})), +})); + jest.mock('utils/exec_commands.ts', () => ({ focusAndInsertText: (element: HTMLElement, text: string) => { element.focus(); @@ -44,6 +53,7 @@ jest.mock('utils/exec_commands.ts', () => ({ const mockedRemoveDraft = jest.mocked(removeDraft); const mockedUpdateDraft = jest.mocked(updateDraft); +const mockedOnSubmit = jest.mocked(onSubmit); const currentUserId = 'current_user_id'; const channelId = 'current_channel_id'; @@ -195,6 +205,10 @@ const baseProps = { }; describe('components/avanced_text_editor/advanced_text_editor', () => { + afterEach(() => { + jest.useRealTimers(); + }); + describe('keyDown behavior', () => { it('ESC should blur the input', async () => { renderWithContext( @@ -247,8 +261,6 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT + 50); expect(mockedRemoveDraft).toHaveBeenCalled(); expect(mockedUpdateDraft).not.toHaveBeenCalled(); - - jest.useRealTimers(); }); }); @@ -287,6 +299,225 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { expect(screen.getByPlaceholderText('Write to Other Channel')).toHaveValue('a different draft'); }); + it('should submit a destination-owned draft while the textbox still holds the previous channel value', async () => { + const sourceDraft = 'stale draft from the source channel'; + const destinationMessage = 'new message composed for the destination channel'; + const sourceFileInfo = TestHelper.getFileInfoMock({id: 'source-file-id', name: 'source-file.txt'}); + const destinationFileInfo = TestHelper.getFileInfoMock({id: 'destination-file-id', name: 'destination-file.txt'}); + const sourceMetadata = {priority: {priority: PostPriority.URGENT}, files: [sourceFileInfo]}; + const destinationMetadata = {priority: {priority: PostPriority.IMPORTANT}, files: [destinationFileInfo]}; + const sourceProps = {sourceOnly: 'source-prop'}; + const destinationProps = {destinationOnly: 'destination-prop'}; + const typeOnSwitchRef = {current: false}; + + function Harness({editorChannelId}: {editorChannelId: string}) { + const [sendDestinationMessage, setSendDestinationMessage] = React.useState(false); + + // Cmd+K can focus the composer after channelId updates but before the + // composer's effect replaces the local draft. Layout effects run before + // that effect, so typing here lands in the window the user reported. + React.useLayoutEffect(() => { + if (!typeOnSwitchRef.current) { + return; + } + typeOnSwitchRef.current = false; + + const textbox = screen.getByPlaceholderText('Write to Other Channel'); + expect(textbox).toHaveValue(sourceDraft); + + // SuggestionBox listens to onInput, not onChange. + fireEvent.input(textbox, {target: {value: destinationMessage}}); + setSendDestinationMessage(true); + }, [editorChannelId]); + + // The typed message is applied on the next render, still before the + // draft-swap effect, so sending from this layout effect submits while + // draft.channelId is still the source channel. + React.useLayoutEffect(() => { + if (!sendDestinationMessage) { + return; + } + + fireEvent.click(screen.getByTestId('SendMessageButton')); + }, [sendDestinationMessage]); + + return ( + + ); + } + + const {rerender} = renderWithContext( + , + mergeObjects(initialState, { + storage: { + storage: { + [StoragePrefixes.DRAFT + channelId]: { + value: TestHelper.getPostDraftMock({ + message: sourceDraft, + channelId, + metadata: sourceMetadata, + type: PostTypes.BURN_ON_READ as PostType, + props: sourceProps, + }), + }, + [StoragePrefixes.DRAFT + otherChannelId]: { + value: TestHelper.getPostDraftMock({ + message: '', + channelId: otherChannelId, + metadata: destinationMetadata, + type: PostTypes.ME as PostType, + props: destinationProps, + }), + }, + }, + }, + }), + ); + + expect(screen.getByPlaceholderText('Write to Test Channel')).toHaveValue(sourceDraft); + + typeOnSwitchRef.current = true; + rerender(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockedOnSubmit).toHaveBeenCalledWith( + otherChannelId, + '', + expect.objectContaining({ + message: destinationMessage, + channelId: otherChannelId, + rootId: '', + fileInfos: [destinationFileInfo], + metadata: destinationMetadata, + type: PostTypes.ME, + props: destinationProps, + }), + expect.anything(), + undefined, + ); + expect(mockedOnSubmit).toHaveBeenCalledWith( + otherChannelId, + '', + expect.not.objectContaining({ + fileInfos: [sourceFileInfo], + metadata: sourceMetadata, + type: PostTypes.BURN_ON_READ, + props: sourceProps, + }), + expect.anything(), + undefined, + ); + }); + + it('should persist text typed during a channel switch only under the destination draft', async () => { + jest.useFakeTimers(); + + const sourceDraft = 'stale draft from the source channel'; + const destinationMessage = 'new message composed for the destination channel'; + const sourceFileInfo = TestHelper.getFileInfoMock({id: 'source-file-id', name: 'source-file.txt'}); + const destinationFileInfo = TestHelper.getFileInfoMock({id: 'destination-file-id', name: 'destination-file.txt'}); + const sourceMetadata = {priority: {priority: PostPriority.URGENT}, files: [sourceFileInfo]}; + const destinationMetadata = {priority: {priority: PostPriority.IMPORTANT}, files: [destinationFileInfo]}; + const sourceProps = {sourceOnly: 'source-prop'}; + const destinationProps = {destinationOnly: 'destination-prop'}; + const typeOnSwitchRef = {current: false}; + + function Harness({editorChannelId}: {editorChannelId: string}) { + React.useLayoutEffect(() => { + if (!typeOnSwitchRef.current) { + return; + } + typeOnSwitchRef.current = false; + + const textbox = screen.getByPlaceholderText('Write to Other Channel'); + expect(textbox).toHaveValue(sourceDraft); + fireEvent.input(textbox, {target: {value: destinationMessage}}); + }, [editorChannelId]); + + return ( + + ); + } + + const {rerender} = renderWithContext( + , + mergeObjects(initialState, { + storage: { + storage: { + [StoragePrefixes.DRAFT + channelId]: { + value: TestHelper.getPostDraftMock({ + message: sourceDraft, + channelId, + metadata: sourceMetadata, + type: PostTypes.BURN_ON_READ as PostType, + props: sourceProps, + }), + }, + [StoragePrefixes.DRAFT + otherChannelId]: { + value: TestHelper.getPostDraftMock({ + message: '', + channelId: otherChannelId, + metadata: destinationMetadata, + type: PostTypes.ME as PostType, + props: destinationProps, + }), + }, + }, + }, + }), + ); + + mockedUpdateDraft.mockClear(); + typeOnSwitchRef.current = true; + rerender(); + + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT + 50); + }); + + expect(mockedUpdateDraft).toHaveBeenCalledWith( + StoragePrefixes.DRAFT + otherChannelId, + expect.objectContaining({ + message: destinationMessage, + channelId: otherChannelId, + rootId: '', + fileInfos: [destinationFileInfo], + metadata: destinationMetadata, + type: PostTypes.ME, + props: destinationProps, + }), + '', + ); + expect(mockedUpdateDraft).not.toHaveBeenCalledWith( + StoragePrefixes.DRAFT + channelId, + expect.objectContaining({message: destinationMessage}), + expect.anything(), + ); + expect(mockedUpdateDraft).not.toHaveBeenCalledWith( + StoragePrefixes.DRAFT + otherChannelId, + expect.objectContaining({ + fileInfos: [sourceFileInfo], + metadata: sourceMetadata, + type: PostTypes.BURN_ON_READ, + props: sourceProps, + }), + expect.anything(), + ); + }); + it('should save a new draft when changing channels', async () => { const {rerender} = renderWithContext( { }); }); + it('should not adopt the previous channel draft when a submit resolves after a channel switch', async () => { + let resolveSubmit = () => {}; + mockedOnSubmit.mockImplementation((() => () => new Promise((resolve) => { + resolveSubmit = () => resolve({data: true}); + })) as unknown as typeof onSubmit); + + const {rerender} = renderWithContext( + , + initialState, + ); + + await userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), 'first message'); + await userEvent.click(screen.getByTestId('SendMessageButton')); + + // The channel switches while that submit is still in flight, exactly as + // /msg does when redirecting to a DM that already exists in the store. + rerender( + , + ); + + // Now the in-flight submit resolves and clears the origin channel's draft. + await act(async () => { + resolveSubmit(); + }); + + await userEvent.type(screen.getByPlaceholderText('Write to Other Channel'), 'second message'); + + // Switching away flushes the composer's draft, revealing which channel it + // believes it belongs to. Before the fix this was the origin channel, so + // the message would have posted there. + mockedUpdateDraft.mockClear(); + rerender( + , + ); + + expect(mockedUpdateDraft).toHaveBeenCalled(); + expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({ + message: 'second message', + channelId: otherChannelId, + }); + }); + + it('should not adopt the previous thread draft when a submit resolves after a thread switch', async () => { + let resolveSubmit = () => {}; + mockedOnSubmit.mockImplementation((() => () => new Promise((resolve) => { + resolveSubmit = () => resolve({data: true}); + })) as unknown as typeof onSubmit); + + const firstThreadId = 'thread_1'; + const secondThreadId = 'thread_2'; + + const {rerender} = renderWithContext( + , + initialState, + ); + + await userEvent.type(screen.getByPlaceholderText('Reply to this thread...'), 'first reply'); + await userEvent.click(screen.getByTestId('SendMessageButton')); + + rerender( + , + ); + + await act(async () => { + resolveSubmit(); + }); + + await userEvent.type(screen.getByPlaceholderText('Reply to this thread...'), 'second reply'); + + mockedUpdateDraft.mockClear(); + rerender( + , + ); + + expect(mockedUpdateDraft).toHaveBeenCalled(); + expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({ + message: 'second reply', + channelId, + rootId: secondThreadId, + }); + }); + it('MM-60541 should not save an unmodified draft when changing channels', async () => { const {rerender} = renderWithContext( getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true)); const isDMOrGMRemote = isChannelShared && (channelType === Constants.DM_CHANNEL || channelType === Constants.GM_CHANNEL); + if (draft.channelId !== channelId || draft.rootId !== rootId) { + setDraft(draftFromStore); + } + const handleShowPreview = useCallback(() => { setShowPreview((prev) => !prev); }, []); @@ -255,7 +259,17 @@ const AdvancedTextEditor = ({ clearTimeout(saveDraftFrame.current); } - setDraft(draftToChange); + // A late async callback (slow submit, finished file upload) may call handleDraftChange + // with the channelId/rootId captured when it started. If the user has since moved to + // another channel or thread, do not overwrite the text they have typed here. + setDraft((currentDraft) => { + if (currentDraft.channelId !== draftToChange.channelId || currentDraft.rootId !== draftToChange.rootId) { + // The current channel/thread has changed, so don't update the draft displayed to the user + return currentDraft; + } + + return draftToChange; + }); const saveDraft = () => { let prefix = StoragePrefixes.DRAFT; @@ -695,13 +709,11 @@ const AdvancedTextEditor = ({ handleSubmitWithErrorHandling(undefined, schedulingInfo); }, [handleSubmitWithErrorHandling]); - // Set the draft from store when changing post or channels, and store the previous one + // Store the previous draft when changing post or channels useEffect(() => { // Store the draft that existed when we opened the channel to know if it should be saved const draftOnOpen = draftFromStore; - setDraft(draftOnOpen); - return () => { if (draftOnOpen !== draftRef.current) { handleDraftChange(draftRef.current, {instant: true, show: true}); diff --git a/webapp/channels/src/components/advanced_text_editor/use_submit.test.tsx b/webapp/channels/src/components/advanced_text_editor/use_submit.test.tsx index c914b48a4acc..c88e0777d0e4 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_submit.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_submit.test.tsx @@ -19,6 +19,11 @@ jest.mock('actions/views/modals', () => ({ openModal: jest.fn(() => ({type: ''})), })); +jest.mock('actions/views/create_comment', () => ({ + ...jest.requireActual('actions/views/create_comment'), + onSubmit: jest.fn(() => () => Promise.resolve({data: true})), +})); + describe('useSubmit', () => { const mockDraft: PostDraft = { message: 'Test message', diff --git a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx index 10d9b2d3741b..eeb4080a6ad2 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx @@ -204,7 +204,7 @@ const useSubmit = ( response = await dispatch(editPost(submittingDraft as unknown as Post)); handleFileChange(submittingDraft); } else { - response = await dispatch(onSubmit(submittingDraft, options, schedulingInfo)); + response = await dispatch(onSubmit(channelId, rootId, submittingDraft, options, schedulingInfo)); } if (response?.error) { throw response.error; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 841b7219c291..8ef97de2974e 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1350,8 +1350,6 @@ "admin.experimental.emailBatchingInterval.desc": "Specify the maximum frequency, in seconds, which the batching job checks for new notifications. Longer batching intervals will increase performance.", "admin.experimental.emailBatchingInterval.example": "E.g.: \"30\"", "admin.experimental.emailBatchingInterval.title": "Email Batching Interval:", - "admin.experimental.enableUserDeactivation.desc": "When true, users may deactivate their own account from **Settings > Advanced**. If a user deactivates their own account, they will get an email notification confirming they were deactivated. When false, users may not deactivate their own account.", - "admin.experimental.enableUserDeactivation.title": "Enable Account Deactivation:", "admin.experimental.enableWatermark.desc": "When true, authenticated mobile sessions will display a watermark overlay showing the username, domain, date (YYYY-MM-DD), and time (HH:mm) for data loss prevention (DLP) purposes.", "admin.experimental.enableWatermark.title": "Enable Mobile Watermark:", "admin.experimental.experimentalEnableAuthenticationTransfer.desc": "When true, users can change their sign-in method to any that is enabled on the server, either via their Profile or the APIs. When false, Users cannot change their sign-in method, regardless of which authentication options are enabled.", @@ -1360,8 +1358,6 @@ "admin.experimental.experimentalEnableAutomaticReplies.title": "Enable Automatic Replies:", "admin.experimental.experimentalEnableDefaultChannelLeaveJoinMessages.desc": "This setting determines whether team leave/join system messages are posted in the default town-square channel.", "admin.experimental.experimentalEnableDefaultChannelLeaveJoinMessages.title": "Enable Default Channel Leave/Join System Messages:", - "admin.experimental.experimentalEnableHardenedMode.desc": "Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.", - "admin.experimental.experimentalEnableHardenedMode.title": "Enable Hardened Mode:", "admin.experimental.experimentalFeatures": "Experimental Features", "admin.experimental.experimentalPrimaryTeam.desc": "The primary team of which users on the server are members. When a primary team is set, the options to join other teams or leave the primary team are disabled.", "admin.experimental.experimentalPrimaryTeam.example": "E.g.: \"teamname\"", @@ -1388,9 +1384,6 @@ "admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.desc": "The number of milliseconds to wait between fetching user statuses and profiles periodically.", "admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.example": "E.g.: \"5000\"", "admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.title": "User's Status and Profile Fetching Poll Interval:", - "admin.experimental.userStatusAwayTimeout.desc": "This setting defines the number of seconds after which the user's status indicator changes to \"Away\", when they are away from Mattermost.", - "admin.experimental.userStatusAwayTimeout.example": "E.g.: \"300\"", - "admin.experimental.userStatusAwayTimeout.title": "User Status Away Timeout:", "admin.experimental.youtubeReferrerPolicy.desc": "When true, the referrer policy for embedded YouTube videos will be set to \"strict-origin-when-cross-origin\" which resolves issues where YouTube video previews display as unavailable, while balancing the need to protect user privacy with some degree of referral data to support web functionalities, like analytics, logging, and third-party integrations. When false, the referrer policy will be set to \"no-referrer\" which enhances user privacy by not disclosing the source URL, but limits the ability to track user engagement and traffic sources in analytics tools.", "admin.experimental.youtubeReferrerPolicy.title": "YouTube Referrer Policy:", "admin.false": "False", @@ -3267,6 +3260,8 @@ "admin.service.googleDescription": "Set this key to enable the display of titles for embedded YouTube video previews. Without the key, YouTube previews will still be created based on hyperlinks appearing in messages or comments but they will not show the video title. View a Google Developers Tutorial for instructions on how to obtain a key and add YouTube Data API v3 as a service to your key.", "admin.service.googleExample": "E.g.: \"7rAh6iwQCkV4cA1Gsg3fgGOXJAQ43QV\"", "admin.service.googleTitle": "Google API Key:", + "admin.service.hardenedModeDesc": "Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.", + "admin.service.hardenedModeTitle": "Enable Hardened Mode:", "admin.service.iconDescription": "When true, webhooks, slash commands and other integrations will be allowed to change the profile picture they post with. Note: Combined with allowing integrations to override usernames, users may be able to perform phishing attacks by attempting to impersonate other users.", "admin.service.iconTitle": "Enable integrations to override profile picture icons:", "admin.service.insecureTlsDesc": "When true, any outgoing HTTPS requests will accept unverified, self-signed certificates. For example, outgoing webhooks to a server with a self-signed TLS certificate, using any domain, will be allowed. Note that this makes these connections susceptible to man-in-the-middle attacks.", @@ -3889,6 +3884,8 @@ "admin.team.enableChannelCategorySortingTitle": "Channel category sorting:", "admin.team.enableJoinLeaveMessageDescription": "Choose the default configuration of system messages displayed when users join or leave channels. Users can override this default by configuring Join/Leave messages in Account Settings > Advanced.", "admin.team.enableJoinLeaveMessageTitle": "Enable join/leave messages by default:", + "admin.team.enableUserDeactivationDescription": "When true, users may deactivate their own account from **Settings > Advanced**. If a user deactivates their own account, they will get an email notification confirming they were deactivated. When false, users may not deactivate their own account. Only available to users who sign in with an email address and password.", + "admin.team.enableUserDeactivationTitle": "Enable Account Deactivation:", "admin.team.invalidateEmailInvitesDescription": "This will invalidate active email invitations that have not been accepted by the user. By default email invitations expire after 48 hours.", "admin.team.invalidateEmailInvitesFail": "Unable to invalidate pending email invites: {error}", "admin.team.invalidateEmailInvitesSuccess": "Pending email invitations invalidated successfully", @@ -3934,6 +3931,9 @@ "admin.team.uploadDesc": "Customize your user experience by adding a custom image to your login screen. Recommended maximum image size is less than 2 MB.", "admin.team.userCreationDescription": "When false, the ability to create accounts is disabled, and selecting Create Account displays an error. Applies to Email, OpenID Connect, and OAuth 2.0 user account authentication.", "admin.team.userCreationTitle": "Enable Account Creation: ", + "admin.team.userStatusAwayTimeoutDescription": "This setting defines the number of seconds of inactivity after which the user's status indicator changes to \"Away\".", + "admin.team.userStatusAwayTimeoutExample": "E.g.: \"300\"", + "admin.team.userStatusAwayTimeoutTitle": "User Status Away Timeout (seconds):", "admin.teamChannelSettings.usersToBeRemovedModal.title": "{total, number} {total, plural, one {User} other {Users}} To Be Removed", "admin.trial_banner.trial-request.error": "Trial license could not be retrieved. Visit {trialInfoLink} to request a license.", "admin.trial_banner.upgrade_unsupported": "Quick upgrade is only supported on Linux systems with x86-64 architecture. Learn how to upgrade manually.", diff --git a/webapp/channels/src/utils/admin_console_index.test.tsx b/webapp/channels/src/utils/admin_console_index.test.tsx index 94d52166a488..ddc4b0413e9f 100644 --- a/webapp/channels/src/utils/admin_console_index.test.tsx +++ b/webapp/channels/src/utils/admin_console_index.test.tsx @@ -37,10 +37,20 @@ describe('AdminConsoleIndex.generateIndex', () => { expect(idx.search('channel_viewed')).toEqual([ 'environment/web_server', ]); + expect(idx.search('hardened')).toEqual([ + 'environment/web_server', + ]); expect(idx.search('characters')).toEqual([ 'site_config/customization', 'authentication/password', ]); + expect(idx.search('deactivation')).toEqual([ + 'authentication/saml', + 'site_config/users_and_teams', + ]); + expect(idx.search('away')).toEqual([ + 'site_config/users_and_teams', + ]); expect(idx.search('typing')).toEqual([ 'experimental/features', 'site_config/posts', diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 7f7de1fa1516..69a9f5810c95 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -406,7 +406,7 @@ export type ServiceSettings = { EnableAPITeamDeletion: boolean; EnableAPITriggerAdminNotifications: boolean; EnableAPIUserDeletion: boolean; - ExperimentalEnableHardenedMode: boolean; + EnableHardenedMode: boolean; ExperimentalStrictCSRFEnforcement: boolean; EnableEmailInvitations: boolean; DisableBotsWhenOwnerIsDeactivated: boolean;