From 116728424cb805e2c685ea2192737dbb98b7a35f Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Tue, 11 Jul 2023 09:04:39 -0400 Subject: [PATCH] [MM-53124] Add optional Forgot Password custom link to override the default flow on the login page (#23831) * [MM-53124] Add optional Forgot Password custom link to override the default flow on the login page * Fix i18n * Fix test * Added the enable/disable flag * Fix test * Fix e2e * Add blockable link for the Customization navigation --- .../support/server/default_config.ts | 3 + server/config/client.go | 2 + server/public/model/config.go | 24 +++- server/tests/test-config.json | 4 +- .../admin_console/admin_definition.jsx | 12 +- ...ord_settings.jsx => password_settings.tsx} | 116 +++++++++++++----- .../src/components/login/login.test.tsx | 1 + .../channels/src/components/login/login.tsx | 39 ++++-- webapp/channels/src/i18n/en.json | 4 + webapp/platform/types/src/config.ts | 4 + 10 files changed, 161 insertions(+), 48 deletions(-) rename webapp/channels/src/components/admin_console/{password_settings.jsx => password_settings.tsx} (71%) diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index eec3d2d068..1362aa5cde 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -44,6 +44,7 @@ const onPremServerConfig = (): Partial => { Number: false, Uppercase: false, Symbol: false, + EnableForgotLink: true, }, PluginSettings: { EnableUploads: true, @@ -265,6 +266,7 @@ const defaultServerConfig: AdminConfig = { Number: false, Uppercase: false, Symbol: false, + EnableForgotLink: true, }, FileSettings: { EnableFileAttachments: true, @@ -345,6 +347,7 @@ const defaultServerConfig: AdminConfig = { AboutLink: 'https://docs.mattermost.com/pl/about-mattermost', HelpLink: 'https://mattermost.com/pl/help/', ReportAProblemLink: 'https://mattermost.com/pl/report-a-bug', + ForgotPasswordLink: '', SupportEmail: '', CustomTermsOfServiceEnabled: false, CustomTermsOfServiceReAcceptancePeriod: 365, diff --git a/server/config/client.go b/server/config/client.go index 7ed39094ba..9654647fc5 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -272,6 +272,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["AboutLink"] = *c.SupportSettings.AboutLink props["HelpLink"] = *c.SupportSettings.HelpLink props["ReportAProblemLink"] = *c.SupportSettings.ReportAProblemLink + props["ForgotPasswordLink"] = *c.SupportSettings.ForgotPasswordLink props["SupportEmail"] = *c.SupportSettings.SupportEmail props["EnableAskCommunityLink"] = strconv.FormatBool(*c.SupportSettings.EnableAskCommunityLink) @@ -295,6 +296,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["PasswordRequireUppercase"] = strconv.FormatBool(*c.PasswordSettings.Uppercase) props["PasswordRequireNumber"] = strconv.FormatBool(*c.PasswordSettings.Number) props["PasswordRequireSymbol"] = strconv.FormatBool(*c.PasswordSettings.Symbol) + props["PasswordEnableForgotLink"] = strconv.FormatBool(*c.PasswordSettings.EnableForgotLink) // Set default values for all options that require a license. props["EnableCustomBrand"] = "false" diff --git a/server/public/model/config.go b/server/public/model/config.go index cdb9ab8c64..8aa6f5ce9f 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -1487,11 +1487,12 @@ func (s *NotificationLogSettings) GetAdvancedLoggingConfig() []byte { } type PasswordSettings struct { - MinimumLength *int `access:"authentication_password"` - Lowercase *bool `access:"authentication_password"` - Number *bool `access:"authentication_password"` - Uppercase *bool `access:"authentication_password"` - Symbol *bool `access:"authentication_password"` + MinimumLength *int `access:"authentication_password"` + Lowercase *bool `access:"authentication_password"` + Number *bool `access:"authentication_password"` + Uppercase *bool `access:"authentication_password"` + Symbol *bool `access:"authentication_password"` + EnableForgotLink *bool `access:"authentication_password"` } func (s *PasswordSettings) SetDefaults() { @@ -1514,6 +1515,10 @@ func (s *PasswordSettings) SetDefaults() { if s.Symbol == nil { s.Symbol = NewBool(false) } + + if s.EnableForgotLink == nil { + s.EnableForgotLink = NewBool(true) + } } type FileSettings struct { @@ -1882,6 +1887,7 @@ type SupportSettings struct { AboutLink *string `access:"site_customization,write_restrictable,cloud_restrictable"` HelpLink *string `access:"site_customization"` ReportAProblemLink *string `access:"site_customization,write_restrictable,cloud_restrictable"` + ForgotPasswordLink *string `access:"site_customization,write_restrictable,cloud_restrictable"` SupportEmail *string `access:"site_notifications"` CustomTermsOfServiceEnabled *bool `access:"compliance_custom_terms_of_service"` CustomTermsOfServiceReAcceptancePeriod *int `access:"compliance_custom_terms_of_service"` @@ -1929,6 +1935,14 @@ func (s *SupportSettings) SetDefaults() { s.ReportAProblemLink = NewString(SupportSettingsDefaultReportAProblemLink) } + if !isSafeLink(s.ForgotPasswordLink) { + *s.ForgotPasswordLink = "" + } + + if s.ForgotPasswordLink == nil { + s.ForgotPasswordLink = NewString("") + } + if s.SupportEmail == nil { s.SupportEmail = NewString(SupportSettingsDefaultSupportEmail) } diff --git a/server/tests/test-config.json b/server/tests/test-config.json index 7493403a71..d422afbe47 100644 --- a/server/tests/test-config.json +++ b/server/tests/test-config.json @@ -115,7 +115,8 @@ "Lowercase": false, "Number": false, "Uppercase": false, - "Symbol": false + "Symbol": false, + "EnableForgotLink": true }, "FileSettings": { "EnableFileAttachments": true, @@ -187,6 +188,7 @@ "AboutLink": "https://mattermost.com/default-about/", "HelpLink": "https://mattermost.com/pl/help/", "ReportAProblemLink": "https://mattermost.com/pl/report-a-bug", + "ForgotPasswordLink": "", "SupportEmail": "feedback@mattermost.com" }, "AnnouncementSettings": { diff --git a/webapp/channels/src/components/admin_console/admin_definition.jsx b/webapp/channels/src/components/admin_console/admin_definition.jsx index 645ef34322..5a0dd6688c 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.jsx +++ b/webapp/channels/src/components/admin_console/admin_definition.jsx @@ -55,7 +55,7 @@ import TeamSettings from './team_channel_settings/team'; import TeamDetails from './team_channel_settings/team/details'; import ChannelSettings from './team_channel_settings/channel'; import ChannelDetails from './team_channel_settings/channel/details'; -import PasswordSettings from './password_settings.jsx'; +import PasswordSettings from './password_settings'; import PushNotificationsSettings from './push_settings'; import DataRetentionSettings from './data_retention_settings'; import GlobalDataRetentionForm from './data_retention_settings/global_policy_form'; @@ -2135,6 +2135,16 @@ const AdminDefinition = { isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)), isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), }, + { + type: Constants.SettingsTypes.TYPE_TEXT, + key: 'SupportSettings.ForgotPasswordLink', + label: t('admin.support.forgotPasswordTitle'), + label_default: 'Forgot Password Custom Link:', + help_text: t('admin.support.forgotPasswordDesc'), + help_text_default: 'The URL for the Forgot Password link on the Mattermost login page. If this field is empty the Forgot Password link takes users to the Password Reset page.', + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)), + isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), + }, { type: Constants.SettingsTypes.TYPE_TEXT, key: 'SupportSettings.ReportAProblemLink', diff --git a/webapp/channels/src/components/admin_console/password_settings.jsx b/webapp/channels/src/components/admin_console/password_settings.tsx similarity index 71% rename from webapp/channels/src/components/admin_console/password_settings.jsx rename to webapp/channels/src/components/admin_console/password_settings.tsx index f65b528c6a..25d5dde997 100644 --- a/webapp/channels/src/components/admin_console/password_settings.jsx +++ b/webapp/channels/src/components/admin_console/password_settings.tsx @@ -4,17 +4,38 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {AdminConfig} from '@mattermost/types/config'; +import {DeepPartial} from '@mattermost/types/utilities'; + import Constants from 'utils/constants'; import * as Utils from 'utils/utils'; import {t} from 'utils/i18n'; -import AdminSettings from './admin_settings'; +import AdminSettings, {BaseProps, BaseState} from './admin_settings'; +import BooleanSetting from './boolean_setting'; import Setting from './setting'; import SettingsGroup from './settings_group'; import TextSetting from './text_setting'; +import BlockableLink from './blockable_link'; -export default class PasswordSettings extends AdminSettings { - constructor(props) { +type Props = BaseProps & { + config: AdminConfig; +}; + +type State = BaseState & { + passwordMinimumLength?: string; + passwordLowercase?: boolean; + passwordNumber?: boolean; + passwordUppercase?: boolean; + passwordSymbol?: boolean; + passwordEnableForgotLink?: boolean; + maximumLoginAttempts?: string; +}; + +export default class PasswordSettings extends AdminSettings { + sampleErrorMsg: React.ReactNode; + + constructor(props: Props) { super(props); this.state = Object.assign(this.state, { @@ -23,6 +44,7 @@ export default class PasswordSettings extends AdminSettings { passwordNumber: props.config.PasswordSettings.Number, passwordUppercase: props.config.PasswordSettings.Uppercase, passwordSymbol: props.config.PasswordSettings.Symbol, + passwordEnableForgotLink: props.config.PasswordSettings.EnableForgotLink, maximumLoginAttempts: props.config.ServiceSettings.MaximumLoginAttempts, }); @@ -59,7 +81,7 @@ export default class PasswordSettings extends AdminSettings { this.sampleErrorMsg = ( { - config.PasswordSettings.MinimumLength = this.parseIntNonZero(this.state.passwordMinimumLength, Constants.MIN_PASSWORD_LENGTH); - config.PasswordSettings.Lowercase = this.state.passwordLowercase; - config.PasswordSettings.Uppercase = this.state.passwordUppercase; - config.PasswordSettings.Number = this.state.passwordNumber; - config.PasswordSettings.Symbol = this.state.passwordSymbol; + getConfigFromState = (config: DeepPartial) => { + if (config.PasswordSettings) { + config.PasswordSettings.MinimumLength = this.parseIntNonZero(this.state.passwordMinimumLength ?? '', Constants.MIN_PASSWORD_LENGTH); + config.PasswordSettings.Lowercase = this.state.passwordLowercase; + config.PasswordSettings.Uppercase = this.state.passwordUppercase; + config.PasswordSettings.Number = this.state.passwordNumber; + config.PasswordSettings.Symbol = this.state.passwordSymbol; + config.PasswordSettings.EnableForgotLink = this.state.passwordEnableForgotLink; + } - config.ServiceSettings.MaximumLoginAttempts = this.parseIntNonZero(this.state.maximumLoginAttempts, Constants.MAXIMUM_LOGIN_ATTEMPTS_DEFAULT); + if (config.ServiceSettings) { + config.ServiceSettings.MaximumLoginAttempts = this.parseIntNonZero(this.state.maximumLoginAttempts ?? '', Constants.MAXIMUM_LOGIN_ATTEMPTS_DEFAULT); + } return config; }; - getStateFromConfig(config) { + getStateFromConfig(config: DeepPartial) { return { - passwordMinimumLength: config.PasswordSettings.MinimumLength, - passwordLowercase: config.PasswordSettings.Lowercase, - passwordNumber: config.PasswordSettings.Number, - passwordUppercase: config.PasswordSettings.Uppercase, - passwordSymbol: config.PasswordSettings.Symbol, - maximumLoginAttempts: config.ServiceSettings.MaximumLoginAttempts, + passwordMinimumLength: String(config.PasswordSettings?.MinimumLength), + passwordLowercase: config.PasswordSettings?.Lowercase, + passwordNumber: config.PasswordSettings?.Number, + passwordUppercase: config.PasswordSettings?.Uppercase, + passwordSymbol: config.PasswordSettings?.Symbol, + passwordEnableForgotLink: config.PasswordSettings?.EnableForgotLink, + maximumLoginAttempts: String(config.ServiceSettings?.MaximumLoginAttempts), }; } @@ -96,7 +124,7 @@ export default class PasswordSettings extends AdminSettings { return ( ); } @@ -116,7 +144,7 @@ export default class PasswordSettings extends AdminSettings { return ( { - this.handleChange(id, value); - }; - - handleCheckboxChange = (id) => { - return ({target: {checked}}) => { - this.handleChange(id, checked); + handleCheckboxChange = (id: string) => { + return (event: React.ChangeEvent) => { + this.handleChange(id, event.target.checked); }; }; @@ -167,10 +191,11 @@ export default class PasswordSettings extends AdminSettings { }} /> } - value={this.state.passwordMinimumLength} - onChange={this.handlePasswordLengthChange} + value={this.state.passwordMinimumLength ?? ''} + onChange={this.handleChange} setByEnv={this.isSetByEnv('PasswordSettings.MinimumLength')} disabled={this.props.isDisabled} + type='input' /> } - value={this.state.maximumLoginAttempts} + value={this.state.maximumLoginAttempts ?? ''} onChange={this.handleChange} setByEnv={this.isSetByEnv('ServiceSettings.MaximumLoginAttempts')} disabled={this.props.isDisabled} + type='input' /> ) } + + } + helpText={ + ( + + {chunks} + + ), + }} + /> + } + value={this.state.passwordEnableForgotLink ?? false} + setByEnv={false} + onChange={this.handleChange} + disabled={this.props.isDisabled} + /> ); }; diff --git a/webapp/channels/src/components/login/login.test.tsx b/webapp/channels/src/components/login/login.test.tsx index 512c98856f..e29fc5ee30 100644 --- a/webapp/channels/src/components/login/login.test.tsx +++ b/webapp/channels/src/components/login/login.test.tsx @@ -125,6 +125,7 @@ describe('components/login/Login', () => { CustomDescriptionText: '', SiteName: 'Mattermost', ExperimentalPrimaryTeam: '', + PasswordEnableForgotLink: 'true', }; }); diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index 68bf1c8b19..d0689b6164 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -59,6 +59,7 @@ import {setCSRFFromCookie} from 'utils/utils'; import LoginMfa from './login_mfa'; import './login.scss'; +import ExternalLink from 'components/external_link'; const MOBILE_SCREEN_WIDTH = 1200; @@ -98,6 +99,8 @@ const Login = ({onCustomizeHeader}: LoginProps) => { CustomDescriptionText, SiteName, ExperimentalPrimaryTeam, + ForgotPasswordLink, + PasswordEnableForgotLink, } = useSelector(getConfig); const {IsLicensed} = useSelector(getLicense); const initializing = useSelector((state: GlobalState) => state.requests.users.logout.status === RequestStatus.SUCCESS || !state.storage.initialized); @@ -703,6 +706,34 @@ const Login = ({onCustomizeHeader}: LoginProps) => { ); }; + const getResetPasswordLink = () => { + if (!PasswordEnableForgotLink || PasswordEnableForgotLink === 'false') { + return null; + } + + if (ForgotPasswordLink) { + return ( +
+ + {formatMessage({id: 'login.forgot', defaultMessage: 'Forgot your password?'})} + +
+ ); + } + + if (enableSignInWithUsername || enableSignInWithEmail) { + return ( +
+ + {formatMessage({id: 'login.forgot', defaultMessage: 'Forgot your password?'})} + +
+ ); + } + + return null; + }; + const getContent = () => { if (showMfa) { return ( @@ -802,13 +833,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => { hasError={hasError} disabled={isWaiting} /> - {(enableSignInWithUsername || enableSignInWithEmail) && ( -
- - {formatMessage({id: 'login.forgot', defaultMessage: 'Forgot your password?'})} - -
- )} + {getResetPasswordLink()}