From ed36e8bd645f78e08ac181e64d63dbd3ad6b23dc Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Tue, 4 Apr 2023 13:57:32 +0300 Subject: [PATCH] [MM-51236] - Add opt-in checkbox in self-hosted account creation page (#22709) * [MM-51236] - Add opt-in checkbox in self-hosted account creation page * add server impl * fix lint and translations * add tests * improvents * fix model * fix mocks * feedback impl * update text color * handle error * fix tests --- model/hosted_customer.go | 6 ++ server/channels/api4/hosted_customer.go | 32 ++++++++ server/channels/einterfaces/cloud.go | 1 + .../einterfaces/mocks/CloudInterface.go | 14 ++++ server/i18n/en.json | 4 + .../signup/__snapshots__/signup.test.tsx.snap | 44 +++++++++++ .../src/components/signup/signup.scss | 16 ++++ .../src/components/signup/signup.test.tsx | 51 +++++++++++- .../channels/src/components/signup/signup.tsx | 78 ++++++++++++++++++- .../widgets/inputs/check/check.scss | 19 +++++ .../components/widgets/inputs/check/index.tsx | 28 +++++++ webapp/channels/src/i18n/en.json | 3 + webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 8 ++ webapp/platform/types/src/cloud.ts | 5 ++ 15 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 webapp/channels/src/components/widgets/inputs/check/check.scss create mode 100644 webapp/channels/src/components/widgets/inputs/check/index.tsx diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 543ea12b74..608892e5e5 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -8,6 +8,12 @@ type BootstrapSelfHostedSignupRequest struct { Reset bool `json:"reset"` } +type SubscribeNewsletterRequest struct { + Email string `json:"email"` + ServerID string `json:"server_id"` + SubscribedContent string `json:"subscribed_content"` +} + type BootstrapSelfHostedSignupResponse struct { Progress string `json:"progress"` // email listed on the JWT claim diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index bbed311ea4..ba791c28cc 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -36,6 +36,8 @@ func (api *API) InitHostedCustomer() { api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET") // GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf api.BaseRoutes.HostedCustomer.Handle("/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(selfHostedInvoicePDF)).Methods("GET") + + api.BaseRoutes.HostedCustomer.Handle("/subscribe-newsletter", api.APIHandler(handleSubscribeToNewsletter)).Methods(http.MethodPost) } func ensureSelfHostedAdmin(c *Context, where string) { @@ -293,3 +295,33 @@ func selfHostedInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) { r, ) } + +func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.handleSubscribeToNewsletter" + ensured := ensureCloudInterface(c, where) + if !ensured { + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + req := new(model.SubscribeNewsletterRequest) + err = json.Unmarshal(bodyBytes, req) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + req.ServerID = c.App.Srv().TelemetryId() + + if err := c.App.Cloud().SubscribeToNewsletter("", req); err != nil { + c.Err = model.NewAppError(where, "api.server.cws.subscribe_to_newsletter.app_error", nil, "CWS Server failed to subscribe to newsletter.", http.StatusInternalServerError).Wrap(err) + return + } + + ReturnStatusOK(w) +} diff --git a/server/channels/einterfaces/cloud.go b/server/channels/einterfaces/cloud.go index b5d6a75b68..70cdc4676a 100644 --- a/server/channels/einterfaces/cloud.go +++ b/server/channels/einterfaces/cloud.go @@ -47,4 +47,5 @@ type CloudInterface interface { CheckCWSConnection(userId string) error SelfServeDeleteWorkspace(userID string, deletionRequest *model.WorkspaceDeletionRequest) error + SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error } diff --git a/server/channels/einterfaces/mocks/CloudInterface.go b/server/channels/einterfaces/mocks/CloudInterface.go index db7c86acc2..2dd2711650 100644 --- a/server/channels/einterfaces/mocks/CloudInterface.go +++ b/server/channels/einterfaces/mocks/CloudInterface.go @@ -540,6 +540,20 @@ func (_m *CloudInterface) SelfServeDeleteWorkspace(userID string, deletionReques return r0 } +// SubscribeToNewsletter provides a mock function with given fields: userID, req +func (_m *CloudInterface) SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error { + ret := _m.Called(userID, req) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *model.SubscribeNewsletterRequest) error); ok { + r0 = rf(userID, req) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateCloudCustomer provides a mock function with given fields: userID, customerInfo func (_m *CloudInterface) UpdateCloudCustomer(userID string, customerInfo *model.CloudCustomerInfo) (*model.CloudCustomer, error) { ret := _m.Called(userID, customerInfo) diff --git a/server/i18n/en.json b/server/i18n/en.json index 506628e0bb..e73fa524c8 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2591,6 +2591,10 @@ "id": "api.server.cws.needs_enterprise_edition", "translation": "Service only available in Mattermost Enterprise edition" }, + { + "id": "api.server.cws.subscribe_to_newsletter.app_error", + "translation": "CWS Server failed to subscribe to newsletter." + }, { "id": "api.server.hosted_signup_unavailable.error", "translation": "Portal unavailable for self-hosted signup." diff --git a/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap b/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap index 691b7341e9..7ab9e4ee2c 100644 --- a/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap +++ b/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap @@ -93,6 +93,28 @@ exports[`components/signup/Signup should match snapshot for all signup options e onChange={[Function]} value="" /> +
+ + Interested in receiving Mattermost security updates via newsletter? + + + Sign up at + + https://mattermost.com/security-updates/ + + . + +
+
+ + Interested in receiving Mattermost security updates via newsletter? + + + Sign up at + + https://mattermost.com/security-updates/ + + . + +
; let mockDispatch = jest.fn(); @@ -96,7 +98,7 @@ describe('components/signup/Signup', () => { beforeEach(() => { mockLocation = {pathname: '', search: '', hash: ''}; - mockLicense = {IsLicensed: 'true'}; + mockLicense = {IsLicensed: 'true', Cloud: 'false'}; mockState = { entities: { @@ -178,7 +180,7 @@ describe('components/signup/Signup', () => { }); it('should match snapshot for all signup options enabled with isLicensed disabled', () => { - mockLicense = {IsLicensed: 'false'}; + mockLicense = {IsLicensed: 'false', Cloud: 'false'}; const wrapper = shallow( , @@ -295,4 +297,45 @@ describe('components/signup/Signup', () => { expect(wrapper.find('.content-layout-column-title').text()).toEqual('This invite link is invalid'); }); }); + + it('should show newsletter check box opt-in for self-hosted non airgapped workspaces', async () => { + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + mockLicense = {IsLicensed: 'true', Cloud: 'false'}; + + const {container: signupContainer} = renderWithIntlAndStore( + + + , {}); + + screen.getByTestId('signup-body-card-form-check-newsletter'); + const checkInput = screen.getByTestId('signup-body-card-form-check-newsletter'); + expect(checkInput).toHaveAttribute('type', 'checkbox'); + + expect(signupContainer).toHaveTextContent(/I would like to receive Mattermost security updates via newsletter. Data Terms and Conditions apply/); + }); + + it('should NOT show newsletter check box opt-in for self-hosted AND airgapped workspaces', async () => { + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => false); + mockLicense = {IsLicensed: 'true', Cloud: 'false'}; + + const {container: signupContainer} = renderWithIntlAndStore( + + + , {}); + + expect(() => screen.getByTestId('signup-body-card-form-check-newsletter')).toThrow(); + expect(signupContainer).toHaveTextContent('Interested in receiving Mattermost security updates via newsletter?Sign up at https://mattermost.com/security-updates/.'); + }); + + it('should not show any newsletter related opt-in or text for cloud', async () => { + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + mockLicense = {IsLicensed: 'true', Cloud: 'true'}; + + renderWithIntlAndStore( + + + , {}); + + expect(() => screen.getByTestId('signup-body-card-form-check-newsletter')).toThrow(); + }); }); diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index dde147597f..3f3a13d460 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -48,9 +48,12 @@ import LoginOpenIDIcon from 'components/widgets/icons/login_openid_icon'; import LoginOffice365Icon from 'components/widgets/icons/login_office_365_icon'; import Input, {CustomMessageInputType, SIZE} from 'components/widgets/inputs/input/input'; import PasswordInput from 'components/widgets/inputs/password_input/password_input'; +import CheckInput from 'components/widgets/inputs/check'; import SaveButton from 'components/save_button'; +import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; +import ExternalLink from 'components/external_link'; -import {Constants, ItemStatus, ValidationErrors} from 'utils/constants'; +import {Constants, HostedCustomerLinks, ItemStatus, ValidationErrors} from 'utils/constants'; import {isValidUsername, isValidPassword, getPasswordConfig, getRoleFromTrackFlow, getMediumFromTrackFlow} from 'utils/utils'; import './signup.scss'; @@ -99,7 +102,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { TermsOfServiceLink, PrivacyPolicyLink, } = config; - const {IsLicensed} = useSelector(getLicense); + const {IsLicensed, Cloud} = useSelector(getLicense); const loggedIn = Boolean(useSelector(getCurrentUserId)); const useCaseOnboarding = useSelector(getUseCaseOnboarding); const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined)); @@ -110,6 +113,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const passwordInput = useRef(null); const isLicensed = IsLicensed === 'true'; + const isCloud = Cloud === 'true'; const enableOpenServer = EnableOpenServer === 'true'; const noAccounts = NoAccounts === 'true'; const enableSignUpWithEmail = EnableSignUpWithEmail === 'true'; @@ -136,12 +140,24 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const [teamName, setTeamName] = useState(parsedTeamName ?? ''); const [alertBanner, setAlertBanner] = useState(null); const [isMobileView, setIsMobileView] = useState(false); + const [subscribeToSecurityNewsletter, setSubscribeToSecurityNewsletter] = useState(false); + + const canReachCWS = useCWSAvailabilityCheck(); const enableExternalSignup = enableSignUpWithGitLab || enableSignUpWithOffice365 || enableSignUpWithGoogle || enableSignUpWithOpenId || enableLDAP || enableSAML; const hasError = Boolean(emailError || nameError || passwordError || serverError || alertBanner); const canSubmit = Boolean(email && name && password) && !hasError && !loading; const {error: passwordInfo} = isValidPassword('', getPasswordConfig(config), intl); + const subscribeToSecurityNewsletterFunc = () => { + try { + Client4.subscribeToNewsletter({email, subscribed_content: 'security_newsletter'}); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + }; + const getExternalSignupOptions = () => { const externalLoginOptions: ExternalLoginButtonType[] = []; @@ -564,6 +580,9 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } await handleSignupSuccess(user, data as UserProfile); + if (subscribeToSecurityNewsletter) { + subscribeToSecurityNewsletterFunc(); + } } else { setIsWaiting(false); } @@ -571,6 +590,60 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const handleReturnButtonOnClick = () => history.replace('/'); + const getNewsletterCheck = () => { + if (isCloud) { + return null; + } + + if (canReachCWS) { + return ( + setSubscribeToSecurityNewsletter(!subscribeToSecurityNewsletter)} + text={ + formatMessage( + {id: 'newsletter_optin.checkmark.text', defaultMessage: 'I would like to receive Mattermost security updates via newsletter. Data Terms and Conditions apply'}, + { + a: (chunks: React.ReactNode | React.ReactNodeArray) => ( + + {chunks} + + ), + }, + )} + checked={subscribeToSecurityNewsletter} + /> + ); + } + return ( +
+ + {formatMessage({id: 'newsletter_optin.title', defaultMessage: 'Interested in receiving Mattermost security updates via newsletter?'})} + + + {formatMessage( + {id: 'newsletter_optin.desc', defaultMessage: 'Sign up at {link}.'}, + { + link: HostedCustomerLinks.SECURITY_UPDATES, + a: (chunks: React.ReactNode | React.ReactNodeArray) => ( + + {chunks} + + ), + }, + )} + +
+ ); + }; + const handleOnBlur = (e: FocusEvent, inputId: string) => { const text = e.target.value; if (!text) { @@ -736,6 +809,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { error={passwordError} onBlur={(e) => handleOnBlur(e, 'password')} /> + {getNewsletterCheck()} void; + checked: boolean; +} + +function CheckInput(props: Props) { + return ( +
+ + {props.text} +
+ ); +} + +export default CheckInput; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index e273515c5a..c1f0bacbc9 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4254,6 +4254,9 @@ "navbar.viewPinnedPosts": "View Pinned Posts", "newChannelWithBoard.tutorialTip.description": "The board you just created can be quickly accessed by clicking on the Boards icon in the App bar. You can view the boards that are linked to this channel in the right-hand sidebar and open one in full view.", "newChannelWithBoard.tutorialTip.title": "Access linked boards from the App Bar", + "newsletter_optin.checkmark.text": "I would like to receive Mattermost security updates via newsletter. Data Terms and Conditions apply", + "newsletter_optin.desc": "Sign up at {link}.", + "newsletter_optin.title": "Interested in receiving Mattermost security updates via newsletter?", "next_steps_view.welcomeToMattermost": "Welcome to Mattermost", "no_results.channel_files_filtered.subtitle": "This channel doesn't contains any file with the selected file format.", "no_results.channel_files_filtered.title": "No files found", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 7ea14fc0fa..d7e4644af2 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1077,6 +1077,7 @@ export const HostedCustomerLinks = { BILLING_DOCS: 'https://mattermost.com/pl/how-self-hosted-billing-works', SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', TERMS_AND_CONDITIONS: 'https://mattermost.com/enterprise-edition-terms/', + SECURITY_UPDATES: 'https://mattermost.com/security-updates/', }; export const DocLinks = { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 063c706732..a58b1655ba 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -26,6 +26,7 @@ import { CreateSubscriptionRequest, Feedback, WorkspaceDeletionRequest, + NewsletterRequestBody, } from '@mattermost/types/cloud'; import { SelfHostedSignupForm, @@ -3893,6 +3894,13 @@ export default class Client4 { ); }; + subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => { + return this.doFetch( + `${this.getHostedCustomerRoute()}/subscribe-newsletter`, + {method: 'post', body: JSON.stringify(newletterRequestBody)}, + ); + }; + createPaymentMethod = async () => { return this.doFetch( `${this.getCloudRoute()}/payment`, diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 36a586f7be..74b7195ab2 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -220,6 +220,11 @@ export interface CreateSubscriptionRequest { internal_purchase_order?: string; } +export interface NewsletterRequestBody { + email: string; + subscribed_content: string; +} + export const areShippingDetailsValid = (address: Address | null | undefined): boolean => { if (!address) { return false;