diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 637212b93111..8b1e5a59dc43 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -973,6 +973,9 @@ is_gov_sku: type: boolean description: Whether this is a government SKU license + is_non_production: + type: boolean + description: Whether the license is a non-production (developer) key customer: type: object properties: diff --git a/server/channels/api4/team_local.go b/server/channels/api4/team_local.go index 0a1c71d05ce7..a9cdbe2b0035 100644 --- a/server/channels/api4/team_local.go +++ b/server/channels/api4/team_local.go @@ -163,6 +163,12 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) if !isEmailAddressAllowed(emailAddress, allowedDomains) { invite.Error = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": emailAddress}, "", http.StatusBadRequest) errList = append(errList, model.EmailInviteWithErrorToString(invite)) + } else if deactivated, userErr := c.App.IsDeactivatedUserEmail(emailAddress); userErr != nil { + invite.Error = userErr + errList = append(errList, model.EmailInviteWithErrorToString(invite)) + } else if deactivated { + invite.Error = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.account_deactivated.app_error", map[string]any{"Addresses": emailAddress}, "", http.StatusBadRequest) + errList = append(errList, model.EmailInviteWithErrorToString(invite)) } else { goodEmails = append(goodEmails, emailAddress) } @@ -218,9 +224,9 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) } else { var invalidEmailList []string - for _, email := range emailList { - if !isEmailAddressAllowed(email, allowedDomains) { - invalidEmailList = append(invalidEmailList, email) + for _, emailAddr := range emailList { + if !isEmailAddressAllowed(emailAddr, allowedDomains) { + invalidEmailList = append(invalidEmailList, emailAddr) } } if len(invalidEmailList) > 0 { @@ -228,6 +234,10 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) c.Err = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": s}, "", http.StatusBadRequest) return } + if c.Err = c.App.CheckForDeactivatedInvites("localInviteUsersToTeam", emailList); c.Err != nil { + return + } + err := c.App.Srv().EmailService.SendInviteEmails(c.AppContext, email.InviteEmailData{ Team: team, SenderName: "Administrator", diff --git a/server/channels/api4/team_test.go b/server/channels/api4/team_test.go index 6247dcaf2fe4..d978b5ed1205 100644 --- a/server/channels/api4/team_test.go +++ b/server/channels/api4/team_test.go @@ -4285,6 +4285,35 @@ func TestInviteUsersToTeam(t *testing.T) { }, "rate limits") } +func TestLocalInviteUsersToTeamDeactivatedUser(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableEmailInvitations = true + }) + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + t.Cleanup(func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser2, true) + }) + + t.Run("non-graceful local invite returns error for deactivated user", func(t *testing.T) { + _, err := th.LocalClient.InviteUsersToTeam(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Email}) + require.Error(t, err) + CheckErrorID(t, err, "api.team.invite_members.account_deactivated.app_error") + }) + + t.Run("graceful local invite returns error for deactivated user", func(t *testing.T) { + invitesWithErrors, _, err := th.LocalClient.InviteUsersToTeamGracefully(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Email}) + require.NoError(t, err) + require.Len(t, invitesWithErrors, 1) + require.NotNil(t, invitesWithErrors[0].Error) + CheckErrorID(t, invitesWithErrors[0].Error, "api.team.invite_members.account_deactivated.app_error") + }) +} + func TestInviteUsersToTeamWithProfiles(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 8457f2f4e494..cf86c65624b7 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -2938,6 +2938,12 @@ func switchAccountType(c *Context, w http.ResponseWriter, r *http.Request) { return } + if c.AppContext.Session().IsOAuth { + c.SetPermissionError(model.PermissionEditOtherUsers) + c.Err.DetailedError += ", attempted access by oauth app" + return + } + link, err = c.App.SwitchOAuthToEmail(c.AppContext, switchRequest.Email, switchRequest.NewPassword, c.AppContext.Session().UserId) } else if switchRequest.EmailToLdap() { link, err = c.App.SwitchEmailToLdap(c.AppContext, switchRequest.Email, switchRequest.Password, switchRequest.MfaCode, switchRequest.LdapLoginId, switchRequest.NewPassword) diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 3dc92fa51af0..41b62c53dc78 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -5695,6 +5695,35 @@ func TestSwitchAccount(t *testing.T) { require.Equal(t, "/login?extra=signin_change", link) }) + t.Run("OAuth app session cannot switch to email", func(t *testing.T) { + setupUserAuth(t, model.UserAuthServiceGitlab, true) + + session, appErr := th.App.GetSession(th.Client.AuthToken) + require.Nil(t, appErr) + session.IsOAuth = true + th.App.AddSessionToCache(session) + t.Cleanup(func() { + th.Server.Platform().ClearUserSessionCacheLocal(th.BasicUser.Id) + }) + + sr := &model.SwitchRequest{ + CurrentService: model.UserAuthServiceGitlab, + NewService: model.UserAuthServiceEmail, + Email: th.BasicUser.Email, + NewPassword: model.NewTestPassword(), + } + + _, resp, err := th.Client.SwitchAccountType(context.Background(), sr) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // The account must remain attached to its login provider + th.App.InvalidateCacheForUser(th.BasicUser.Id) + user, appErr := th.App.GetUser(th.BasicUser.Id) + require.Nil(t, appErr) + require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) + }) + t.Run("Disabled if EnableSignUpWithEmail is false", func(t *testing.T) { setupUserAuth(t, model.UserAuthServiceGitlab, true) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.EmailSettings.EnableSignUpWithEmail = false }) diff --git a/server/channels/app/oauth.go b/server/channels/app/oauth.go index 0216c27a71e9..4ff37f95b914 100644 --- a/server/channels/app/oauth.go +++ b/server/channels/app/oauth.go @@ -1211,6 +1211,10 @@ func (a *App) SwitchOAuthToEmail(rctx request.CTX, email, password, requesterId return "", model.NewAppError("oauthToEmail", "api.user.oauth_to_email.not_available.app_error", nil, "", http.StatusForbidden) } + if rctx.Session().IsOAuth { + return "", model.NewAppError("SwitchOAuthToEmail", "api.user.oauth_to_email.integration_session.app_error", nil, "", http.StatusForbidden) + } + if !*a.Config().EmailSettings.EnableSignUpWithEmail { return "", model.NewAppError("SwitchOAuthToEmail", "api.user.auth_switch.not_available.email_signup_disabled.app_error", nil, "", http.StatusForbidden) } diff --git a/server/channels/app/oauth_test.go b/server/channels/app/oauth_test.go index 7624ef71f231..24b82257eaa5 100644 --- a/server/channels/app/oauth_test.go +++ b/server/channels/app/oauth_test.go @@ -1621,6 +1621,55 @@ func TestOAuthImplicitGrantRejectsDeactivatedUser(t *testing.T) { require.Empty(t, accessData, "no access data may be persisted for an inactive user") } +func TestSwitchOAuthToEmail(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + setupOAuthUser := func(t *testing.T) *model.User { + t.Helper() + + authData := model.NewId() + _, err := th.App.Srv().Store().User().UpdateAuthData(th.BasicUser.Id, model.UserAuthServiceGitlab, &authData, th.BasicUser.Email, true) + require.NoError(t, err) + th.App.InvalidateCacheForUser(th.BasicUser.Id) + + user, appErr := th.App.GetUser(th.BasicUser.Id) + require.Nil(t, appErr) + require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) + + return user + } + + t.Run("rejects integration session", func(t *testing.T) { + user := setupOAuthUser(t) + + rctx := th.Context.WithSession(&model.Session{UserId: user.Id, Id: model.NewId(), IsOAuth: true}) + + _, appErr := th.App.SwitchOAuthToEmail(rctx, user.Email, model.NewTestPassword(), user.Id) + require.NotNil(t, appErr) + require.Equal(t, "api.user.oauth_to_email.integration_session.app_error", appErr.Id) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + + user, appErr = th.App.GetUser(user.Id) + require.Nil(t, appErr) + require.Equal(t, model.UserAuthServiceGitlab, user.AuthService) + }) + + t.Run("allows regular session", func(t *testing.T) { + user := setupOAuthUser(t) + + rctx := th.Context.WithSession(&model.Session{UserId: user.Id, Id: model.NewId()}) + + link, appErr := th.App.SwitchOAuthToEmail(rctx, user.Email, model.NewTestPassword(), user.Id) + require.Nil(t, appErr) + require.Equal(t, "/login?extra=signin_change", link) + + user, appErr = th.App.GetUser(user.Id) + require.Nil(t, appErr) + require.Empty(t, user.AuthService) + }) +} + func TestParseOAuthStateTokenExtra(t *testing.T) { t.Run("valid token with normal values", func(t *testing.T) { email, action, cookie, err := parseOAuthStateTokenExtra("user@example.com:email_to_sso:randomcookie123") diff --git a/server/channels/app/platform/license.go b/server/channels/app/platform/license.go index b9b3abcdeb30..5836b404870b 100644 --- a/server/channels/app/platform/license.go +++ b/server/channels/app/platform/license.go @@ -412,6 +412,7 @@ func (ps *PlatformService) logLicense(message string, license *model.License) { mlog.String("sku_short_name", license.SkuShortName), mlog.Bool("is_trial", license.IsTrial), mlog.Bool("is_gov_sku", license.IsGovSku), + mlog.Bool("is_non_production", license.IsNonProduction), ) if license.Customer != nil { diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go index ad3e72aa01d7..dd2f23e13bd8 100644 --- a/server/channels/app/platform/support_packet.go +++ b/server/channels/app/platform/support_packet.go @@ -152,6 +152,7 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model d.License.SkuShortName = license.SkuShortName d.License.IsTrial = license.IsTrial d.License.IsGovSKU = license.IsGovSku + d.License.IsNonProduction = license.IsNonProduction } /* Server */ diff --git a/server/channels/app/team.go b/server/channels/app/team.go index ae4e83aba80e..7e3fe3b69ed0 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -1560,6 +1560,39 @@ func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string, channelIds [] return user, team, channels, nil } +func (a *App) IsDeactivatedUserEmail(email string) (bool, *model.AppError) { + existingUser, appErr := a.GetUserByEmail(email) + if appErr != nil { + if appErr.Id == MissingAccountError { + return false, nil + } + return false, appErr + } + return existingUser.DeleteAt != 0, nil +} + +// CheckForDeactivatedInvites returns an error if any email belongs to a +// deactivated account. where identifies the caller in the returned AppError. +func (a *App) CheckForDeactivatedInvites(where string, emailList []string) *model.AppError { + var deactivatedEmailList []string + for _, email := range emailList { + deactivated, userErr := a.IsDeactivatedUserEmail(email) + if userErr != nil { + return userErr + } + if deactivated { + deactivatedEmailList = append(deactivatedEmailList, email) + } + } + + if len(deactivatedEmailList) > 0 { + s := strings.Join(deactivatedEmailList, ", ") + return model.NewAppError(where, "api.team.invite_members.account_deactivated.app_error", map[string]any{"Addresses": s}, "", http.StatusBadRequest) + } + + return nil +} + // isPreSetUsernameAvailable reports whether a username pre-set on an invite is not // already taken by an existing user or group. func (a *App) isPreSetUsernameAvailable(username string) bool { @@ -1668,6 +1701,10 @@ func (a *App) sendInviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite } if !teams.IsEmailAddressAllowed(invitedEmail, allowedDomains) { invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": invitedEmail}, "", http.StatusBadRequest) + } else if deactivated, userErr := a.IsDeactivatedUserEmail(invitedEmail); userErr != nil { + invite.Error = userErr + } else if deactivated { + invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.account_deactivated.app_error", map[string]any{"Addresses": invitedEmail}, "", http.StatusBadRequest) } else if profile := profilesByEmail[invitedEmail]; profile != nil && !a.isPreSetUsernameAvailable(profile.Username) { // Catch taken usernames at invite time so the invitee doesn't dead-end at signup. invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.username_taken.app_error", map[string]any{"Username": profile.Username}, "", http.StatusBadRequest) @@ -1817,6 +1854,10 @@ func (a *App) InviteGuestsToChannelsGracefully(rctx request.CTX, teamID string, } if !users.CheckEmailDomain(email, *a.Config().GuestAccountsSettings.RestrictCreationToDomains) { invite.Error = model.NewAppError("InviteGuestsToChannelsGracefully", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": email}, "", http.StatusBadRequest) + } else if deactivated, userErr := a.IsDeactivatedUserEmail(email); userErr != nil { + invite.Error = userErr + } else if deactivated { + invite.Error = model.NewAppError("InviteGuestsToChannelsGracefully", "api.team.invite_members.account_deactivated.app_error", map[string]any{"Addresses": email}, "", http.StatusBadRequest) } else { goodEmails = append(goodEmails, email) } @@ -1885,6 +1926,10 @@ func (a *App) InviteNewUsersToTeam(rctx request.CTX, emailList []string, teamID, return model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": s}, "", http.StatusBadRequest) } + if err = a.CheckForDeactivatedInvites("InviteNewUsersToTeam", emailList); err != nil { + return err + } + nameFormat := *a.Config().TeamSettings.TeammateNameDisplay eErr := a.Srv().EmailService.SendInviteEmails(rctx, email.InviteEmailData{ Team: team, @@ -1931,6 +1976,10 @@ func (a *App) InviteGuestsToChannels(rctx request.CTX, teamID string, guestsInvi return model.NewAppError("InviteGuestsToChannels", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": s}, "", http.StatusBadRequest) } + if err = a.CheckForDeactivatedInvites("InviteGuestsToChannels", guestsInvite.Emails); err != nil { + return err + } + nameFormat := *a.Config().TeamSettings.TeammateNameDisplay senderProfileImage, _, err := a.GetProfileImage(user) if err != nil { diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go index 4cce3cf762e3..a82846423ebf 100644 --- a/server/channels/app/team_test.go +++ b/server/channels/app/team_test.go @@ -2249,6 +2249,55 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) { require.Equal(t, "api.team.invite_members.username_taken.app_error", res[0].Error.Id) emailServiceMock.AssertNotCalled(t, "SendInviteEmails") }) + + t.Run("it returns error for deactivated user without sending email", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + emailServiceMock.On("Stop").Once().Return() + th.App.Srv().EmailService = &emailServiceMock + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + t.Cleanup(func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser2, true) + }) + + memberInvite := &model.MemberInvite{ + Emails: []string{th.BasicUser2.Email}, + } + + res, err := th.App.InviteNewUsersToTeamGracefully(th.Context, memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") + require.Nil(t, err) + require.Len(t, res, 1) + require.NotNil(t, res[0].Error) + require.Equal(t, "api.team.invite_members.account_deactivated.app_error", res[0].Error.Id) + emailServiceMock.AssertNotCalled(t, "SendInviteEmails") + }) +} + +func TestInviteNewUsersToTeam(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableEmailInvitations = true + }) + + t.Run("it returns error for deactivated user without sending email", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + emailServiceMock.On("Stop").Once().Return() + th.App.Srv().EmailService = &emailServiceMock + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + t.Cleanup(func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser2, true) + }) + + appErr = th.App.InviteNewUsersToTeam(th.Context, []string{th.BasicUser2.Email}, th.BasicTeam.Id, th.BasicUser.Id) + require.NotNil(t, appErr) + require.Equal(t, "api.team.invite_members.account_deactivated.app_error", appErr.Id) + emailServiceMock.AssertNotCalled(t, "SendInviteEmails") + }) } func TestInviteGuestsToChannelsGracefully(t *testing.T) { @@ -2317,6 +2366,59 @@ func TestInviteGuestsToChannelsGracefully(t *testing.T) { require.Len(t, res, 1) require.NotNil(t, res[0].Error) }) + + t.Run("it returns error for deactivated user without sending guest invite email", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + emailServiceMock.On("Stop").Once().Return() + th.App.Srv().EmailService = &emailServiceMock + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + t.Cleanup(func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser2, true) + }) + + res, err := th.App.InviteGuestsToChannelsGracefully(th.Context, th.BasicTeam.Id, &model.GuestsInvite{ + Emails: []string{th.BasicUser2.Email}, + Channels: []string{th.BasicChannel.Id}, + }, th.BasicUser.Id, false) + + require.Nil(t, err) + require.Len(t, res, 1) + require.NotNil(t, res[0].Error) + require.Equal(t, "api.team.invite_members.account_deactivated.app_error", res[0].Error.Id) + emailServiceMock.AssertNotCalled(t, "SendGuestInviteEmails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestInviteGuestsToChannels(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableEmailInvitations = true + }) + + t.Run("it returns error for deactivated user without sending guest invite email", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + emailServiceMock.On("Stop").Once().Return() + th.App.Srv().EmailService = &emailServiceMock + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + t.Cleanup(func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser2, true) + }) + + appErr = th.App.InviteGuestsToChannels(th.Context, th.BasicTeam.Id, &model.GuestsInvite{ + Emails: []string{th.BasicUser2.Email}, + Channels: []string{th.BasicChannel.Id}, + }, th.BasicUser.Id, false) + + require.NotNil(t, appErr) + require.Equal(t, "api.team.invite_members.account_deactivated.app_error", appErr.Id) + emailServiceMock.AssertNotCalled(t, "SendGuestInviteEmails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) } func TestInviteGuestsToChannelsWithPolicyEnforced(t *testing.T) { diff --git a/server/channels/utils/license.go b/server/channels/utils/license.go index 448587cc4c27..b9b8337c3506 100644 --- a/server/channels/utils/license.go +++ b/server/channels/utils/license.go @@ -264,6 +264,7 @@ func GetClientLicense(l *model.License) map[string]string { props["OutgoingOAuthConnections"] = strconv.FormatBool(*l.Features.OutgoingOAuthConnections) props["IsTrial"] = strconv.FormatBool(l.IsTrial) props["IsGovSku"] = strconv.FormatBool(l.IsGovSku) + props["IsNonProduction"] = strconv.FormatBool(l.IsNonProduction) } return props diff --git a/server/channels/utils/license_test.go b/server/channels/utils/license_test.go index 03c7c98cf4f8..34f4b1ce988e 100644 --- a/server/channels/utils/license_test.go +++ b/server/channels/utils/license_test.go @@ -253,6 +253,25 @@ func TestGetLicenseFileLocation(t *testing.T) { require.Equal(t, fileName, "mattermost.mattermost-license", "invalid file name") } +func TestGetClientLicense(t *testing.T) { + license := &model.License{ + Customer: &model.Customer{}, + Features: &model.Features{}, + } + license.Features.SetDefaults() + + props := GetClientLicense(license) + require.Equal(t, "false", props["IsNonProduction"]) + + license.IsNonProduction = true + props = GetClientLicense(license) + require.Equal(t, "true", props["IsNonProduction"]) + + // The flag must survive sanitization so all users can see the non-production banner. + sanitized := GetSanitizedClientLicense(props) + require.Equal(t, "true", sanitized["IsNonProduction"]) +} + func TestGetLicenseFileFromDisk(t *testing.T) { t.Run("missing file", func(t *testing.T) { fileBytes := GetLicenseFileFromDisk("thisfileshouldnotexist.mattermost-license") diff --git a/server/i18n/en.json b/server/i18n/en.json index 3dba75db2fd8..2ca666ba74b3 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3902,6 +3902,10 @@ "id": "api.team.invite_guests_to_channels.license.error", "translation": "Your license does not support guest accounts" }, + { + "id": "api.team.invite_members.account_deactivated.app_error", + "translation": "The account associated with {{.Addresses}} has been deactivated. Please contact your System Administrator for details." + }, { "id": "api.team.invite_members.disabled.app_error", "translation": "Email invitations are disabled." @@ -5054,6 +5058,10 @@ "id": "api.user.oauth_to_email.context.app_error", "translation": "Update password failed because context user_id did not match provided user's id." }, + { + "id": "api.user.oauth_to_email.integration_session.app_error", + "translation": "Unable to switch to email authentication using an integration session." + }, { "id": "api.user.oauth_to_email.magic_link.app_error", "translation": "Magic Link is the only sign-in method available for this account." diff --git a/server/public/model/license.go b/server/public/model/license.go index aa5ad758533a..97f91c843760 100644 --- a/server/public/model/license.go +++ b/server/public/model/license.go @@ -86,6 +86,7 @@ type License struct { SkuShortName string `json:"sku_short_name"` IsTrial bool `json:"is_trial"` IsGovSku bool `json:"is_gov_sku"` + IsNonProduction bool `json:"is_non_production"` IsSeatCountEnforced bool `json:"is_seat_count_enforced"` // ExtraUsers provides a grace mechanism that allows a configurable number of users // beyond the base license limit before restricting user creation. When nil, defaults to 0. diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 4edddfd738cb..5e909bff878f 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -14,11 +14,12 @@ type SupportPacketDiagnostics struct { Version int `yaml:"version"` License struct { - Company string `yaml:"company"` - Users int `yaml:"users"` - SkuShortName string `yaml:"sku_short_name"` - IsTrial bool `yaml:"is_trial,omitempty"` - IsGovSKU bool `yaml:"is_gov_sku,omitempty"` + Company string `yaml:"company"` + Users int `yaml:"users"` + SkuShortName string `yaml:"sku_short_name"` + IsTrial bool `yaml:"is_trial,omitempty"` + IsGovSKU bool `yaml:"is_gov_sku,omitempty"` + IsNonProduction bool `yaml:"is_non_production,omitempty"` } `yaml:"license"` Server struct { diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 6d588b0091df..0d19bf376494 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -1099,6 +1099,11 @@ const AdminDefinition: AdminDefinitionType = { key: 'ServiceSettings.EnableInsecureOutgoingConnections', label: defineMessage({id: 'admin.service.insecureTlsTitle', defaultMessage: 'Enable Insecure Outgoing Connections: '}), help_text: defineMessage({id: 'admin.service.insecureTlsDesc', defaultMessage: '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.'}), + production_warning: { + isEnabled: it.stateIsTrue('ServiceSettings.EnableInsecureOutgoingConnections'), + title: defineMessage({id: 'admin.service.insecureTlsProductionWarning.title', defaultMessage: 'Insecure outgoing connections are not recommended for production environments'}), + text: defineMessage({id: 'admin.service.insecureTlsProductionWarning.text', defaultMessage: 'Mattermost will not verify TLS certificates for outbound connections, exposing them to man-in-the-middle attacks. Enable only for testing.'}), + }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), }, { @@ -1752,6 +1757,11 @@ const AdminDefinition: AdminDefinitionType = { key: 'EmailSettings.SkipServerCertificateVerification', label: defineMessage({id: 'admin.environment.smtp.skipServerCertificateVerification.title', defaultMessage: 'Skip Server Certificate Verification:'}), help_text: defineMessage({id: 'admin.environment.smtp.skipServerCertificateVerification.description', defaultMessage: 'When true, Mattermost will not verify the email server certificate.'}), + production_warning: { + isEnabled: it.stateIsTrue('EmailSettings.SkipServerCertificateVerification'), + title: defineMessage({id: 'admin.environment.smtp.skipServerCertificateVerificationProductionWarning.title', defaultMessage: 'Skipping certificate verification is not recommended for production environments'}), + text: defineMessage({id: 'admin.environment.smtp.skipServerCertificateVerificationProductionWarning.text', defaultMessage: "Mattermost will not validate the SMTP server's TLS certificate, exposing email delivery to man-in-the-middle attacks. Enable only while troubleshooting."}), + }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.SMTP)), }, { @@ -2234,16 +2244,16 @@ const AdminDefinition: AdminDefinitionType = { id: 'ServiceSettings', name: defineMessage({id: 'admin.developer.title', defaultMessage: 'Developer Settings'}), settings: [ - { - type: 'banner', - label: defineMessage({id: 'admin.service.testingWarning', defaultMessage: 'Warning: Testing commands are intended only for isolated non-production environments with test users and sample data. Never enable this setting in production.'}), - banner_type: 'warning', - }, { type: 'bool', key: 'ServiceSettings.EnableTesting', label: defineMessage({id: 'admin.service.testingTitle', defaultMessage: 'Enable Testing Commands:'}), help_text: defineMessage({id: 'admin.service.testingDescription', defaultMessage: 'When true, the /test slash command is enabled to load test accounts, data, and text formatting. Use this setting only in isolated non-production environments and never in production. Changing this requires a server restart before taking effect.'}), + production_warning: { + isEnabled: it.stateIsTrue('ServiceSettings.EnableTesting'), + title: defineMessage({id: 'admin.service.testingProductionWarning.title', defaultMessage: 'Testing commands are not recommended for production environments'}), + text: defineMessage({id: 'admin.service.testingProductionWarning.text', defaultMessage: 'The /test command exposes load-testing and data-generation tools that can modify your data. Enable only in non-production environments.'}), + }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.DEVELOPER)), }, { @@ -2251,6 +2261,11 @@ const AdminDefinition: AdminDefinitionType = { key: 'ServiceSettings.EnableDeveloper', label: defineMessage({id: 'admin.service.developerTitle', defaultMessage: 'Enable Developer Mode: '}), help_text: defineMessage({id: 'admin.service.developerDesc', defaultMessage: 'When true, JavaScript errors are shown in a purple bar at the top of the user interface. Not recommended for use in production. Changing this requires a server restart before taking effect.'}), + production_warning: { + isEnabled: it.stateIsTrue('ServiceSettings.EnableDeveloper'), + title: defineMessage({id: 'admin.service.developerProductionWarning.title', defaultMessage: 'Developer mode is not recommended for production environments'}), + text: defineMessage({id: 'admin.service.developerProductionWarning.text', defaultMessage: 'Developer mode surfaces JavaScript errors in the UI and relaxes web app restrictions. Enable only in development environments.'}), + }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.DEVELOPER)), }, { @@ -4528,6 +4543,11 @@ const AdminDefinition: AdminDefinitionType = { key: 'SamlSettings.Verify', label: defineMessage({id: 'admin.saml.verifyTitle', defaultMessage: 'Verify Signature:'}), help_text: defineMessage({id: 'admin.saml.verifyDescription', defaultMessage: 'When false, Mattermost will not verify that the signature sent from a SAML Response matches the Service Provider Login URL. Disabling verification is not recommended for production environments.'}), + production_warning: { + isEnabled: it.stateIsFalse('SamlSettings.Verify'), + title: defineMessage({id: 'admin.saml.verifyProductionWarning.title', defaultMessage: 'Disabling verification is not recommended for production environments'}), + text: defineMessage({id: 'admin.saml.verifyProductionWarning.text', defaultMessage: 'Without verification, forged SAML responses can authenticate as any user. Disable only while troubleshooting your SAML setup in non-production environments.'}), + }, isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.SAML)), it.stateIsFalse('SamlSettings.Enable'), @@ -4574,6 +4594,11 @@ const AdminDefinition: AdminDefinitionType = { key: 'SamlSettings.Encrypt', label: defineMessage({id: 'admin.saml.encryptTitle', defaultMessage: 'Enable Encryption:'}), help_text: defineMessage({id: 'admin.saml.encryptDescription', defaultMessage: 'When false, Mattermost will not decrypt SAML Assertions encrypted with your Service Provider Public Certificate. Disabling encryption is not recommended for production environments.'}), + production_warning: { + isEnabled: it.stateIsFalse('SamlSettings.Encrypt'), + title: defineMessage({id: 'admin.saml.encryptProductionWarning.title', defaultMessage: 'Disabling encryption is not recommended for production environments'}), + text: defineMessage({id: 'admin.saml.encryptProductionWarning.text', defaultMessage: 'Without encryption, SAML assertions are sent in plain text and may expose user attributes. Disable only while troubleshooting your SAML setup in non-production environments.'}), + }, isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.SAML)), it.stateIsFalse('SamlSettings.Enable'), @@ -6049,6 +6074,11 @@ const AdminDefinition: AdminDefinitionType = { label: defineMessage({id: 'admin.service.corsTitle', defaultMessage: 'Enable cross-origin requests from:'}), placeholder: defineMessage({id: 'admin.service.corsEx', defaultMessage: 'http://example.com'}), help_text: defineMessage({id: 'admin.service.corsDescription', defaultMessage: 'Enable HTTP Cross origin request from a specific domain. Use "*" if you want to allow CORS from any domain or leave it blank to disable it. Should not be set to "*" in production.'}), + production_warning: { + isEnabled: it.stateEquals('ServiceSettings.AllowCorsFrom', '*'), + title: defineMessage({id: 'admin.service.corsProductionWarning.title', defaultMessage: 'Allowing all origins is not recommended for production environments'}), + text: defineMessage({id: 'admin.service.corsProductionWarning.text', defaultMessage: 'Setting allowed origins to a wildcard lets any website make cross-origin requests to your server. Specify explicit trusted origins instead.'}), + }, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.CORS)), }, { diff --git a/webapp/channels/src/components/admin_console/admin_definition_enable_testing.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_enable_testing.test.tsx index b7bdfe0aca46..43454fec4c8d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_enable_testing.test.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_enable_testing.test.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import AdminDefinition from './admin_definition'; -import type {AdminDefinitionSetting, AdminDefinitionSettingBanner} from './types'; +import type {AdminDefinitionSetting} from './types'; describe('AdminDefinition - Enable Testing setting', () => { const getDeveloperSettings = () => { @@ -12,26 +12,6 @@ describe('AdminDefinition - Enable Testing setting', () => { return (schema && 'settings' in schema && schema.settings) ? schema.settings : []; }; - test('includes a warning banner before the EnableTesting setting', () => { - const settings = getDeveloperSettings(); - const bannerIndex = settings.findIndex((setting: AdminDefinitionSetting) => setting.type === 'banner'); - const enableTestingIndex = settings.findIndex((setting: AdminDefinitionSetting) => setting.key === 'ServiceSettings.EnableTesting'); - - expect(bannerIndex).toBeGreaterThanOrEqual(0); - expect(enableTestingIndex).toBeGreaterThanOrEqual(0); - expect(bannerIndex).toBeLessThan(enableTestingIndex); - - const banner = settings[bannerIndex] as AdminDefinitionSettingBanner; - expect(banner.banner_type).toBe('warning'); - expect(typeof banner.label).toBe('object'); - if (banner.label && typeof banner.label === 'object') { - expect(banner.label).toMatchObject({ - id: 'admin.service.testingWarning', - defaultMessage: expect.stringContaining('Never enable this setting in production.'), - }); - } - }); - test('has explicit non-production guidance in the EnableTesting help text', () => { const settings = getDeveloperSettings(); const setting = settings.find((item: AdminDefinitionSetting) => item.key === 'ServiceSettings.EnableTesting'); diff --git a/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx b/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx index 632454b30b86..77f4a3bac291 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx @@ -164,6 +164,11 @@ export const ldapWizardAdminDefinition: LDAPAdminDefinitionConfigSchemaSettings label: defineMessage({id: 'admin.ldap.skipCertificateVerification', defaultMessage: 'Skip Certificate Verification:'}), help_text: defineMessage({id: 'admin.ldap.skipCertificateVerificationDesc', defaultMessage: 'Skips the certificate verification step for TLS or STARTTLS connections.'}), help_text_more_info: defineMessage({id: 'admin.ldap.skipCertificateVerificationDescHover', defaultMessage: 'Skipping certificate verification is not recommended for production environments where TLS is required.'}), + production_warning: { + isEnabled: it.stateIsTrue('LdapSettings.SkipCertificateVerification'), + title: defineMessage({id: 'admin.ldap.skipCertificateVerificationProductionWarning.title', defaultMessage: 'Skipping certificate verification is not recommended for production environments'}), + text: defineMessage({id: 'admin.ldap.skipCertificateVerificationProductionWarning.text', defaultMessage: "Mattermost will not validate the AD/LDAP server's TLS certificate, exposing the connection to man-in-the-middle attacks. Enable only while troubleshooting in non-production environments."}), + }, isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), it.stateIsFalse('LdapSettings.ConnectionSecurity'), diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.test.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.test.tsx new file mode 100644 index 000000000000..0575ce801dfc --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.test.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessage} from 'react-intl'; + +import type {AdminConfig} from '@mattermost/types/config'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import LDAPBooleanSetting from './ldap_boolean_setting'; + +import {it} from '../admin_definition_helpers'; +import type {AdminDefinitionSetting, AdminDefinitionSubSectionSchema} from '../types'; + +describe('components/admin_console/ldap_wizard/LDAPBooleanSetting', () => { + const WARNING_TITLE = 'Skipping certificate verification is not recommended for production environments'; + const WARNING_TEXT = 'Mattermost will not validate the server certificate.'; + + const schema = {id: 'LdapSettings', name: 'ldap'} as AdminDefinitionSubSectionSchema; + + const buildSetting = (): AdminDefinitionSetting => ({ + key: 'LdapSettings.SkipCertificateVerification', + label: 'skip-cert-label', + type: 'bool', + help_text: 'skip-cert-help-text', + production_warning: { + isEnabled: it.stateIsTrue('LdapSettings.SkipCertificateVerification'), + title: defineMessage({id: 'test.ldap.warning.title', defaultMessage: WARNING_TITLE}), + text: defineMessage({id: 'test.ldap.warning.text', defaultMessage: WARNING_TEXT}), + }, + } as unknown as AdminDefinitionSetting); + + const renderSetting = (value: boolean, disabled = false) => renderWithContext( + } + state={{'LdapSettings.SkipCertificateVerification': value}} + />, + ); + + test('renders the danger callout when the LDAP bool setting is at its insecure value', () => { + const {container} = renderSetting(true); + + expect(screen.getByText(WARNING_TITLE)).toBeInTheDocument(); + expect(screen.getByText(WARNING_TEXT)).toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).toBeInTheDocument(); + + // The normal help text still renders alongside the callout. + expect(screen.getByText('skip-cert-help-text')).toBeInTheDocument(); + }); + + test('does not render the callout at the recommended value', () => { + const {container} = renderSetting(false); + + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).not.toBeInTheDocument(); + expect(screen.getByText('skip-cert-help-text')).toBeInTheDocument(); + }); + + test('does not render the callout when the LDAP setting is disabled', () => { + const {container} = renderSetting(true, true); + + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx index c5c130838e93..8493c614b884 100644 --- a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx @@ -4,11 +4,14 @@ import React from 'react'; import {useIntl} from 'react-intl'; +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + import BooleanSetting from 'components/admin_console/boolean_setting'; import {renderLDAPSettingHelpText} from './ldap_helpers'; import type {GeneralSettingProps} from './ldap_wizard'; +import ProductionWarning from '../production_warning'; import {renderLabel} from '../schema_admin_settings'; type BoolSettingProps = { @@ -16,6 +19,9 @@ type BoolSettingProps = { onChange(id: string, value: any): void; disabled: boolean; setByEnv: boolean; + config: Partial; + state: {[x: string]: any}; + license?: ClientLicense; } & GeneralSettingProps; const LDAPBooleanSetting = (props: BoolSettingProps) => { @@ -26,7 +32,18 @@ const LDAPBooleanSetting = (props: BoolSettingProps) => { } const label = renderLabel(props.setting, props.schema, intl); - const helpText = renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + const helpText = ( + <> + + {renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled))} + + ); return ( { disabled={isDisabled(setting)} setByEnv={isSetByEnv(setting.key!, props.environmentConfig)} setting={setting} + config={props.config} + state={state} + license={props.license} /> ); }; diff --git a/webapp/channels/src/components/admin_console/production_warning.tsx b/webapp/channels/src/components/admin_console/production_warning.tsx new file mode 100644 index 000000000000..ebdb166bff9d --- /dev/null +++ b/webapp/channels/src/components/admin_console/production_warning.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + +import SectionNotice from 'components/section_notice'; + +import type {AdminDefinitionSetting} from './types'; + +type Props = { + setting: AdminDefinitionSetting; + config: Partial; + state: {[x: string]: any}; + license?: ClientLicense; + isDisabled?: boolean; +}; + +const ProductionWarning = ({setting, config, state, license, isDisabled}: Props) => { + const intl = useIntl(); + + const warning = setting.production_warning; + if (!warning || isDisabled) { + return null; + } + + const isEnabled = typeof warning.isEnabled === 'function' ? warning.isEnabled(config, state, license) : Boolean(warning.isEnabled); + if (!isEnabled) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default ProductionWarning; diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.test.tsx b/webapp/channels/src/components/admin_console/schema_admin_settings.test.tsx index a2406130d0f1..81925395c59b 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.test.tsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.test.tsx @@ -10,6 +10,7 @@ import type {AdminConfig, EnvironmentConfig} from '@mattermost/types/config'; import {defaultIntl} from 'tests/helpers/intl-test-helper'; import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import {it} from './admin_definition_helpers'; import SchemaAdminSettings, {SchemaAdminSettings as SchemaAdminSettingsClass} from './schema_admin_settings'; import type {ConsoleAccess, AdminDefinitionSubSectionSchema, AdminDefinitionSettingInput} from './types'; import ValidationResult from './validation'; @@ -698,6 +699,192 @@ describe('components/admin_console/SchemaAdminSettings', () => { expect(radioButtons.length).toBeGreaterThan(0); }); + describe('production_warning callout', () => { + const WARNING_TITLE = 'Enabling this is not recommended for production environments'; + const WARNING_TEXT = 'This configuration exposes the server to attacks. Enable only for testing.'; + + const buildBoolWarningSchema = () => ({ + id: 'Config', + name: 'config', + settings: [ + { + key: 'FirstSettings.dangerSetting', + label: 'danger-label', + type: 'bool', + default: false, + help_text: 'danger-help-text', + production_warning: { + isEnabled: it.stateIsTrue('FirstSettings.dangerSetting'), + title: defineMessage({id: 'test.production.warning.title', defaultMessage: WARNING_TITLE}), + text: defineMessage({id: 'test.production.warning.text', defaultMessage: WARNING_TEXT}), + }, + }, + ], + } as unknown as AdminDefinitionSubSectionSchema); + + const renderBoolWarning = (settingValue: boolean) => renderWithContext( + } + environmentConfig={{}} + schema={buildBoolWarningSchema()} + patchConfig={jest.fn()} + />, + ); + + test('renders a danger callout when the bool setting is at its insecure value', () => { + const {container} = renderBoolWarning(true); + + // The danger SectionNotice renders with its title and body copy. + expect(screen.getByText(WARNING_TITLE)).toBeInTheDocument(); + expect(screen.getByText(WARNING_TEXT)).toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).toBeInTheDocument(); + + // The normal help text still renders alongside the callout. + expect(screen.getByText('danger-help-text')).toBeInTheDocument(); + }); + + test('does not render the callout when the bool setting is at its recommended value', () => { + const {container} = renderBoolWarning(false); + + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).not.toBeInTheDocument(); + + // The normal help text is unaffected. + expect(screen.getByText('danger-help-text')).toBeInTheDocument(); + }); + + test('shows and hides the callout reactively as the value is toggled without saving', async () => { + const {container} = renderBoolWarning(false); + + const trueRadio = container.querySelector('[data-testid="FirstSettings.dangerSettingtrue"]') as HTMLInputElement; + const falseRadio = container.querySelector('[data-testid="FirstSettings.dangerSettingfalse"]') as HTMLInputElement; + + // Starts at the recommended value with no callout. + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + + // Selecting the insecure value reveals the callout immediately. + await userEvent.click(trueRadio); + expect(await screen.findByText(WARNING_TITLE)).toBeInTheDocument(); + + // Reverting to the recommended value hides it again. + await userEvent.click(falseRadio); + await waitFor(() => { + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + }); + }); + + test('renders the callout for a text setting only when it matches the insecure value', async () => { + const textWarningSchema = { + id: 'Config', + name: 'config', + settings: [ + { + key: 'FirstSettings.corsSetting', + label: 'cors-label', + type: 'text', + default: '', + help_text: 'cors-help-text', + production_warning: { + isEnabled: it.stateEquals('FirstSettings.corsSetting', '*'), + title: defineMessage({id: 'test.production.cors.title', defaultMessage: WARNING_TITLE}), + text: defineMessage({id: 'test.production.cors.text', defaultMessage: WARNING_TEXT}), + }, + }, + ], + } as unknown as AdminDefinitionSubSectionSchema; + + renderWithContext( + } + environmentConfig={{}} + schema={textWarningSchema} + patchConfig={jest.fn()} + />, + ); + + // A specific trusted origin is safe, so no callout. + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + + // Replacing it with the wildcard origin surfaces the callout. + const textInput = screen.getByRole('textbox', {name: /cors-label/i}); + await userEvent.clear(textInput); + await userEvent.type(textInput, '*'); + expect(await screen.findByText(WARNING_TITLE)).toBeInTheDocument(); + }); + + test('renders the callout when a setting warns on its false value', () => { + const schemaWarnOnFalse = { + id: 'Config', + name: 'config', + settings: [ + { + key: 'FirstSettings.verifySetting', + label: 'verify-label', + type: 'bool', + default: true, + help_text: 'verify-help-text', + production_warning: { + isEnabled: it.stateIsFalse('FirstSettings.verifySetting'), + title: defineMessage({id: 'test.production.verify.title', defaultMessage: WARNING_TITLE}), + text: defineMessage({id: 'test.production.verify.text', defaultMessage: WARNING_TEXT}), + }, + }, + ], + } as unknown as AdminDefinitionSubSectionSchema; + + const {container} = renderWithContext( + } + environmentConfig={{}} + schema={schemaWarnOnFalse} + patchConfig={jest.fn()} + />, + ); + + expect(screen.getByText(WARNING_TITLE)).toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).toBeInTheDocument(); + }); + + test('does not render the callout when the setting is disabled even at the insecure value', () => { + const disabledSchema = { + id: 'Config', + name: 'config', + settings: [ + { + key: 'FirstSettings.dangerSetting', + label: 'danger-label', + type: 'bool', + default: false, + help_text: 'danger-help-text', + isDisabled: true, + production_warning: { + isEnabled: it.stateIsTrue('FirstSettings.dangerSetting'), + title: defineMessage({id: 'test.production.disabled.title', defaultMessage: WARNING_TITLE}), + text: defineMessage({id: 'test.production.disabled.text', defaultMessage: WARNING_TEXT}), + }, + }, + ], + } as unknown as AdminDefinitionSubSectionSchema; + + const {container} = renderWithContext( + } + environmentConfig={{}} + schema={disabledSchema} + patchConfig={jest.fn()} + />, + ); + + expect(screen.queryByText(WARNING_TITLE)).not.toBeInTheDocument(); + expect(container.querySelector('.sectionNoticeContainer.danger')).not.toBeInTheDocument(); + }); + }); + test('should call patchConfig on form submission', async () => { const mockPatchConfig = jest.fn(() => Promise.resolve({data: true})); diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx index dabe9ffbdee0..7a5457faf7e1 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx @@ -42,6 +42,7 @@ import Constants from 'utils/constants'; import {mappingValueFromRoles, rolesFromMapping} from 'utils/policy_roles_adapter'; import PluginMetadataPanel from './plugin_metadata_panel/plugin_metadata_panel'; +import ProductionWarning from './production_warning'; import Setting from './setting'; import type {AdminDefinitionConfigSchemaSection, AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types'; @@ -413,6 +414,22 @@ export class SchemaAdminSettings extends React.PureComponent { + const isDisabled = this.isDisabled(setting); + return ( + <> + + {renderSettingHelpText(setting, this.props.schema, isDisabled)} + + ); + }; + buildButtonSetting = (setting: AdminDefinitionSetting) => { if (!this.props.schema || setting.type !== 'button') { return (<>); @@ -522,7 +539,7 @@ export class SchemaAdminSettings extends React.PureComponent ValidationResult; +export type AdminDefinitionSettingProductionWarning = { + isEnabled: Check; + title: MessageDescriptor; + text: MessageDescriptor; +}; + type AdminDefinitionSettingCustom = Omit & { type: 'custom'; key: string; @@ -52,6 +58,9 @@ type AdminDefinitionSettingBase = { onConfigSave?: (displayVal: any, previousVal?: any) => any; isHidden?: Check; isDisabled?: Check; + + // Danger callout when isEnabled is true. Wired for bool/text settings in schema_admin_settings and ldap_boolean_setting. + production_warning?: AdminDefinitionSettingProductionWarning; }; export type AdminDefinitionSettingBanner = AdminDefinitionSettingBase & { diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index 19c47a3e60cd..cab1a5bdc8f3 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -12,6 +12,7 @@ import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar'; import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar'; import ConfigurationAnnouncementBar from './configuration_bar'; import AnnouncementBar from './default_announcement_bar'; +import NonProductionLicenseAnnouncementBar from './non_production_license_bar'; import NotificationPermissionBar from './notification_permission_bar'; import OverageUsersBanner from './overage_users_banner'; import PaymentAnnouncementBar from './payment_announcement_bar'; @@ -105,6 +106,8 @@ class AnnouncementBarController extends React.PureComponent { // If set with class 'admin-announcement', they will always be visible, stacked vertically. return ( <> + {/* Lowest priority: urgent bars temporarily override it, and it reappears once they clear. */} + {adminConfiguredAnnouncementBar} {errorBar} diff --git a/webapp/channels/src/components/announcement_bar/non_production_license_bar/index.tsx b/webapp/channels/src/components/announcement_bar/non_production_license_bar/index.tsx new file mode 100644 index 000000000000..ff09e3b1946f --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/non_production_license_bar/index.tsx @@ -0,0 +1,38 @@ +// 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 {useSelector} from 'react-redux'; + +import {AlertOutlineIcon} from '@mattermost/compass-icons/components'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; + +import {AnnouncementBarTypes} from 'utils/constants'; + +import AnnouncementBar from '../default_announcement_bar'; + +const NonProductionLicenseAnnouncementBar: React.FC = () => { + const license = useSelector(getLicense); + + if (license?.IsNonProduction !== 'true') { + return null; + } + + return ( + + } + icon={} + /> + ); +}; + +export default NonProductionLicenseAnnouncementBar; diff --git a/webapp/channels/src/components/announcement_bar/non_production_license_bar/non_production_license_bar.test.tsx b/webapp/channels/src/components/announcement_bar/non_production_license_bar/non_production_license_bar.test.tsx new file mode 100644 index 000000000000..f3d3b95ba7ea --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/non_production_license_bar/non_production_license_bar.test.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import NonProductionLicenseAnnouncementBar from './index'; + +describe('components/announcement_bar/NonProductionLicenseAnnouncementBar', () => { + const initialState = { + entities: { + general: { + license: { + IsLicensed: 'true', + IsNonProduction: 'true', + }, + }, + }, + }; + + it('should show banner when license is non-production', () => { + const {container} = renderWithContext( + , + initialState, + ); + + expect(container.querySelector('.announcement-bar')).not.toBeNull(); + expect(container.textContent).toContain('Non-production license. Test or staging use only.'); + }); + + it('should not show banner when license is not non-production', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.general.license.IsNonProduction = 'false'; + + const {container} = renderWithContext( + , + state, + ); + + expect(container.querySelector('.announcement-bar')).toBeNull(); + }); + + it('should not show banner when the flag is absent from the license', () => { + const state = JSON.parse(JSON.stringify(initialState)); + delete state.entities.general.license.IsNonProduction; + + const {container} = renderWithContext( + , + state, + ); + + expect(container.querySelector('.announcement-bar')).toBeNull(); + }); + + it('should not be dismissable', () => { + const {container} = renderWithContext( + , + initialState, + ); + + expect(container.querySelector('.announcement-bar__close')).toBeNull(); + }); + + it('should have advisor type', () => { + const {container} = renderWithContext( + , + initialState, + ); + + expect(container.querySelector('.announcement-bar.announcement-bar-advisor')).not.toBeNull(); + }); +}); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ef92d49f94a7..2020b5940195 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1257,6 +1257,8 @@ "admin.environment.smtp.enableSecurityFixAlert.title": "Enable Security Alerts:", "admin.environment.smtp.skipServerCertificateVerification.description": "When true, Mattermost will not verify the email server certificate.", "admin.environment.smtp.skipServerCertificateVerification.title": "Skip Server Certificate Verification:", + "admin.environment.smtp.skipServerCertificateVerificationProductionWarning.text": "Mattermost will not validate the SMTP server's TLS certificate, exposing email delivery to man-in-the-middle attacks. Enable only while troubleshooting.", + "admin.environment.smtp.skipServerCertificateVerificationProductionWarning.title": "Skipping certificate verification is not recommended for production environments", "admin.environment.smtp.smtpAuth.description": "When true, SMTP Authentication is enabled.", "admin.environment.smtp.smtpAuth.title": "Enable SMTP Authentication:", "admin.environment.smtp.smtpFail": "Connection unsuccessful: {error}", @@ -1862,6 +1864,8 @@ "admin.ldap.skipCertificateVerification": "Skip Certificate Verification:", "admin.ldap.skipCertificateVerificationDesc": "Skips the certificate verification step for TLS or STARTTLS connections.", "admin.ldap.skipCertificateVerificationDescHover": "Skipping certificate verification is not recommended for production environments where TLS is required.", + "admin.ldap.skipCertificateVerificationProductionWarning.text": "Mattermost will not validate the AD/LDAP server's TLS certificate, exposing the connection to man-in-the-middle attacks. Enable only while troubleshooting in non-production environments.", + "admin.ldap.skipCertificateVerificationProductionWarning.title": "Skipping certificate verification is not recommended for production environments", "admin.ldap.sync_button": "AD/LDAP Synchronize Now", "admin.ldap.syncIntervalHelpText": "AD/LDAP Synchronization updates Mattermost user information to reflect updates on the AD/LDAP server. For example, when a user's name changes on the AD/LDAP server, the change updates in Mattermost when synchronization is performed. Accounts removed from or disabled in the AD/LDAP server have their Mattermost accounts set to \"Inactive\" and have their account sessions revoked. Mattermost performs synchronization on the interval entered. For example, if 60 is entered, Mattermost synchronizes every 60 minutes.", "admin.ldap.syncIntervalTitle": "Synchronization Interval (minutes):", @@ -2963,6 +2967,8 @@ "admin.saml.enableSyncWithLdapTitle": "Enable Synchronizing SAML Accounts With AD/LDAP:", "admin.saml.enableTitle": "Enable Login With SAML 2.0:", "admin.saml.encryptDescription": "When false, Mattermost will not decrypt SAML Assertions encrypted with your Service Provider Public Certificate. Disabling encryption is not recommended for production environments.", + "admin.saml.encryptProductionWarning.text": "Without encryption, SAML assertions are sent in plain text and may expose user attributes. Disable only while troubleshooting your SAML setup in non-production environments.", + "admin.saml.encryptProductionWarning.title": "Disabling encryption is not recommended for production environments", "admin.saml.encryptTitle": "Enable Encryption:", "admin.saml.firstnameAttrDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the first name of users in Mattermost.", "admin.saml.firstnameAttrEx": "E.g.: \"FirstName\"", @@ -3035,6 +3041,8 @@ "admin.saml.usernameAttrEx": "E.g.: \"Username\"", "admin.saml.usernameAttrTitle": "Username Attribute:", "admin.saml.verifyDescription": "When false, Mattermost will not verify that the signature sent from a SAML Response matches the Service Provider Login URL. Disabling verification is not recommended for production environments.", + "admin.saml.verifyProductionWarning.text": "Without verification, forged SAML responses can authenticate as any user. Disable only while troubleshooting your SAML setup in non-production environments.", + "admin.saml.verifyProductionWarning.title": "Disabling verification is not recommended for production environments", "admin.saml.verifyTitle": "Verify Signature:", "admin.saving": "Saving Config...", "admin.secure_connection_detail.page_title": "Connection Configuration", @@ -3130,8 +3138,12 @@ "admin.service.corsExposedHeadersDescription": "Whitelist of headers that will be accessible to the requester.", "admin.service.corsExposedHeadersTitle": "CORS Exposed Headers:", "admin.service.corsHeadersEx": "X-My-Header", + "admin.service.corsProductionWarning.text": "Setting allowed origins to a wildcard lets any website make cross-origin requests to your server. Specify explicit trusted origins instead.", + "admin.service.corsProductionWarning.title": "Allowing all origins is not recommended for production environments", "admin.service.corsTitle": "Enable cross-origin requests from:", "admin.service.developerDesc": "When true, JavaScript errors are shown in a purple bar at the top of the user interface. Not recommended for use in production. Changing this requires a server restart before taking effect.", + "admin.service.developerProductionWarning.text": "Developer mode surfaces JavaScript errors in the UI and relaxes web app restrictions. Enable only in development environments.", + "admin.service.developerProductionWarning.title": "Developer mode is not recommended for production environments", "admin.service.developerTitle": "Enable Developer Mode: ", "admin.service.disableBotOwnerDeactivatedTitle": "Disable bot accounts when owner is deactivated:", "admin.service.disableBotWhenOwnerIsDeactivated": "When a user is deactivated, disables all bot accounts managed by the user. To re-enable bot accounts, go to [Integrations > Bot Accounts]({siteURL}/_redirect/integrations/bots).", @@ -3150,6 +3162,8 @@ "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.", + "admin.service.insecureTlsProductionWarning.text": "Mattermost will not verify TLS certificates for outbound connections, exposing them to man-in-the-middle attacks. Enable only for testing.", + "admin.service.insecureTlsProductionWarning.title": "Insecure outgoing connections are not recommended for production environments", "admin.service.insecureTlsTitle": "Enable Insecure Outgoing Connections: ", "admin.service.integrationRequestDesc": "The number of seconds to wait for Integration requests. That includes Slash Commands, Outgoing Webhooks, Interactive Messages and Interactive Dialogs.", "admin.service.integrationRequestTitle": "Integration request timeout: ", @@ -3211,8 +3225,9 @@ "admin.service.terminateSessionsOnPasswordChange.helpText": "When true, all sessions of a user will expire if their password is changed by themselves or an administrator. If password change is initiated by user, their current session is not terminated.", "admin.service.terminateSessionsOnPasswordChange.label": "Terminate Sessions on Password Change: ", "admin.service.testingDescription": "When true, the /test slash command is enabled to load test accounts, data, and text formatting. Use this setting only in isolated non-production environments and never in production. Changing this requires a server restart before taking effect.", + "admin.service.testingProductionWarning.text": "The /test command exposes load-testing and data-generation tools that can modify your data. Enable only in non-production environments.", + "admin.service.testingProductionWarning.title": "Testing commands are not recommended for production environments", "admin.service.testingTitle": "Enable Testing Commands:", - "admin.service.testingWarning": "Warning: Testing commands are intended only for isolated non-production environments with test users and sample data. Never enable this setting in production.", "admin.service.testSiteURL": "Test Live URL", "admin.service.testSiteURLFail": "Test unsuccessful: {error}", "admin.service.testSiteURLSuccess": "Test successful. This is a valid URL.", @@ -4012,6 +4027,7 @@ "announcement_bar.error.trial_license_expiring": "There are {days} days left on your free trial.", "announcement_bar.error.trial_license_expiring_last_day": "This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features.", "announcement_bar.error.trial_license_expiring_last_day.short": "This is the last day of your free trial.", + "announcement_bar.non_production_license.message": "Non-production license. Test or staging use only.", "announcement_bar.notification.email_verified": "Email verified", "announcement_bar.warn.contact_support_email": "Contact support.", "announcement_bar.warn.contact_support_text": "To renew your license, contact support at support@mattermost.com.", diff --git a/webapp/channels/src/sass/routes/_admin-console.scss b/webapp/channels/src/sass/routes/_admin-console.scss index ff8a8b5af02e..d15dd4b8ff10 100644 --- a/webapp/channels/src/sass/routes/_admin-console.scss +++ b/webapp/channels/src/sass/routes/_admin-console.scss @@ -232,6 +232,15 @@ .help-text-warning { color: var(--error-text) } + + .admin-console__production-warning { + margin-bottom: 12px; + white-space: normal; + + .sectionNoticeTitle { + margin-bottom: 0; + } + } } .form-group {