[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
This commit is contained in:
Allan Guwatudde
2023-04-04 13:57:32 +03:00
committed by GitHub
parent bbadbfde45
commit ed36e8bd64
15 changed files with 304 additions and 6 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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."

View File

@@ -93,6 +93,28 @@ exports[`components/signup/Signup should match snapshot for all signup options e
onChange={[Function]}
value=""
/>
<div
className="newsletter"
>
<span
className="interested"
>
Interested in receiving Mattermost security updates via newsletter?
</span>
<span
className="link"
>
Sign up at
<ExternalLink
href="https://mattermost.com/security-updates/"
key=".1"
location="signup"
>
https://mattermost.com/security-updates/
</ExternalLink>
.
</span>
</div>
<SaveButton
btnClass="btn-primary"
defaultMessage="Create account"
@@ -244,6 +266,28 @@ exports[`components/signup/Signup should match snapshot for all signup options e
onChange={[Function]}
value=""
/>
<div
className="newsletter"
>
<span
className="interested"
>
Interested in receiving Mattermost security updates via newsletter?
</span>
<span
className="link"
>
Sign up at
<ExternalLink
href="https://mattermost.com/security-updates/"
key=".1"
location="signup"
>
https://mattermost.com/security-updates/
</ExternalLink>
.
</span>
</div>
<SaveButton
btnClass="btn-primary"
defaultMessage="Create account"

View File

@@ -166,6 +166,22 @@
margin-top: 22px;
}
.newsletter {
margin-top: 24px;
margin-bottom: 32px;
color: var(--center-channel-color-rgb);
font-family: 'Open Sans';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
.interested {
display: block;
font-weight: 600;
}
}
.signup-body-card-form-button-submit {
@include primary-button;
@include button-large;

View File

@@ -5,7 +5,7 @@ import React from 'react';
import {shallow, ReactWrapper} from 'enzyme';
import {IntlProvider} from 'react-intl';
import {BrowserRouter} from 'react-router-dom';
import {act} from '@testing-library/react';
import {act, screen} from '@testing-library/react';
import * as global_actions from 'actions/global_actions';
@@ -15,16 +15,18 @@ import Signup from 'components/signup/signup';
import Input from 'components/widgets/inputs/input/input';
import PasswordInput from 'components/widgets/inputs/password_input/password_input';
import SaveButton from 'components/save_button';
import * as useCWSAvailabilityCheckAll from 'components/common/hooks/useCWSAvailabilityCheck';
import {RequestStatus} from 'mattermost-redux/constants';
import {ClientConfig} from '@mattermost/types/config';
import {GlobalState} from 'types/store';
import {WindowSizes} from 'utils/constants';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
let mockState: GlobalState;
let mockLocation = {pathname: '', search: '', hash: ''};
const mockHistoryPush = jest.fn();
let mockLicense = {IsLicensed: 'true'};
let mockLicense = {IsLicensed: 'true', Cloud: 'false'};
let mockConfig: Partial<ClientConfig>;
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(
<Signup/>,
@@ -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(
<BrowserRouter>
<Signup/>
</BrowserRouter>, {});
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(
<BrowserRouter>
<Signup/>
</BrowserRouter>, {});
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(
<BrowserRouter>
<Signup/>
</BrowserRouter>, {});
expect(() => screen.getByTestId('signup-body-card-form-check-newsletter')).toThrow();
});
});

View File

@@ -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<HTMLInputElement>(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<AlertBannerProps | null>(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 (
<CheckInput
id='signup-body-card-form-check-newsletter'
name='newsletter'
onChange={() => setSubscribeToSecurityNewsletter(!subscribeToSecurityNewsletter)}
text={
formatMessage(
{id: 'newsletter_optin.checkmark.text', defaultMessage: 'I would like to receive Mattermost security updates via newsletter. Data <a>Terms and Conditions</a> apply'},
{
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<ExternalLink
location='signup-newsletter-checkmark'
href={HostedCustomerLinks.SECURITY_UPDATES}
>
{chunks}
</ExternalLink>
),
},
)}
checked={subscribeToSecurityNewsletter}
/>
);
}
return (
<div className='newsletter'>
<span className='interested'>
{formatMessage({id: 'newsletter_optin.title', defaultMessage: 'Interested in receiving Mattermost security updates via newsletter?'})}
</span>
<span className='link'>
{formatMessage(
{id: 'newsletter_optin.desc', defaultMessage: 'Sign up at <a>{link}</a>.'},
{
link: HostedCustomerLinks.SECURITY_UPDATES,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<ExternalLink
location='signup'
href={HostedCustomerLinks.SECURITY_UPDATES}
>
{chunks}
</ExternalLink>
),
},
)}
</span>
</div>
);
};
const handleOnBlur = (e: FocusEvent<HTMLInputElement>, 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()}
<SaveButton
extraClasses='signup-body-card-form-button-submit large'
saving={isWaiting}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.check-input {
display: flex;
align-items: flex-start;
margin-top: 24px;
margin-bottom: 32px;
.text {
margin-left: 8px;
color: var(--center-channel-color-rgb);
font-family: 'Open Sans';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import './check.scss';
type Props = {
id: string;
name: string;
text: ReactNode;
onChange: () => void;
checked: boolean;
}
function CheckInput(props: Props) {
return (
<div className='check-input'>
<input
{...props}
data-testid={props.id}
type='checkbox'
/>
<span className='text'>{props.text}</span>
</div>
);
}
export default CheckInput;

View File

@@ -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 <a>Terms and Conditions</a> apply",
"newsletter_optin.desc": "Sign up at <a>{link}</a>.",
"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",

View File

@@ -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 = {

View File

@@ -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<StatusOK>(
`${this.getHostedCustomerRoute()}/subscribe-newsletter`,
{method: 'post', body: JSON.stringify(newletterRequestBody)},
);
};
createPaymentMethod = async () => {
return this.doFetch(
`${this.getCloudRoute()}/payment`,

View File

@@ -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;