diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 3e8e5ed881..c62f808707 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -170,7 +170,6 @@ const defaultServerConfig: AdminConfig = { EnableCustomGroups: true, SelfHostedPurchase: true, AllowSyncedDrafts: true, - SelfHostedExpansion: false, }, TeamSettings: { SiteName: 'Mattermost', diff --git a/server/model/config.go b/server/model/config.go index af4341bdfa..2d00b9bdd4 100644 --- a/server/model/config.go +++ b/server/model/config.go @@ -390,7 +390,6 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` - SelfHostedExpansion *bool `access:"write_restrictable,cloud_restrictable"` } func (s *ServiceSettings) SetDefaults(isUpdate bool) { @@ -863,10 +862,6 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } - - if s.SelfHostedExpansion == nil { - s.SelfHostedExpansion = NewBool(false) - } } type ClusterSettings struct { diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 4fdbdf51ec..31ea427505 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -476,7 +476,6 @@ func (ts *TelemetryService) trackConfig() { "post_priority": *cfg.ServiceSettings.PostPriority, "self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, - "self_hosted_expansion": *cfg.ServiceSettings.SelfHostedExpansion, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ diff --git a/webapp/channels/src/actions/hosted_customer.tsx b/webapp/channels/src/actions/hosted_customer.tsx index 81d02c63fd..9ae2d350ba 100644 --- a/webapp/channels/src/actions/hosted_customer.tsx +++ b/webapp/channels/src/actions/hosted_customer.tsx @@ -6,7 +6,7 @@ import {Stripe} from '@stripe/stripe-js'; import {getCode} from 'country-list'; import {CreateSubscriptionRequest} from '@mattermost/types/cloud'; -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {SelfHostedExpansionRequest, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; import {ValueOf} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; @@ -198,3 +198,89 @@ export function getTrueUpReviewStatus(): ActionFunc { onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST, }); } + +export function confirmSelfHostedExpansion( + stripe: Stripe, + stripeSetupIntent: StripeSetupIntent, + isDevMode: boolean, + billingDetails: BillingDetails, + initialProgress: ValueOf, + expansionRequest: SelfHostedExpansionRequest, +): ActionFunc { + return async (dispatch: DispatchFunc) => { + const cardSetupFunction = getConfirmCardSetup(isDevMode); + const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup); + + const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress); + if (shouldConfirmCard) { + const result = await confirmCardSetup( + stripeSetupIntent.client_secret, + { + payment_method: { + card: billingDetails.card, + billing_details: { + name: billingDetails.name, + address: { + line1: billingDetails.address, + line2: billingDetails.address2, + city: billingDetails.city, + state: billingDetails.state, + country: getCode(billingDetails.country), + postal_code: billingDetails.postalCode, + }, + }, + }, + }, + ); + + if (!result) { + return {data: false, error: 'failed to confirm card with Stripe'}; + } + + const {setupIntent, error: stripeError} = result; + + if (stripeError) { + if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CONFIRMED_INTENT, + }); + } else { + return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'}; + } + } else { + if (setupIntent === null || setupIntent === undefined) { + return {data: false, error: 'Stripe did not return successful setup intent'}; + } + + if (setupIntent.status !== 'succeeded') { + return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`}; + } + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CONFIRMED_INTENT, + }); + } + } + + let confirmResult; + try { + confirmResult = await Client4.confirmSelfHostedExpansion(stripeSetupIntent.id, expansionRequest); + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: confirmResult.progress, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + // unprocessable entity, e.g. failed export compliance + if (error.status_code === 422) { + return {data: false, error: error.status_code}; + } + return {data: false, error}; + } + + return {data: confirmResult.license}; + }; +} diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 881616d1cf..acce9b4621 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -104,7 +104,7 @@ .license-details-top { display: flex; - justify-content: flex-start; + justify-content: space-between; font-size: 14px; font-weight: 700; line-height: 24px; @@ -114,10 +114,11 @@ color: #3f4350; } - span.expiration-days { - margin-left: auto; - color: var(--denim-status-online); + .add-seats-button { + border-radius: 4px; + font-family: 'Open Sans', sans-serif; font-size: 12px; + font-weight: 600; } } @@ -157,6 +158,20 @@ font-weight: 600; } } + + span.expiration-days { + margin-left: 8px; + font-size: 14px; + font-weight: 600; + + &-warning { + color: var(--sys-away-indicator); + } + + &-danger { + color: var(--dnd-indicator); + } + } } .add-new-licence-btn { diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index 603aa6807b..987a3421f5 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -6,17 +6,31 @@ import {screen} from '@testing-library/react'; import {Provider} from 'react-redux'; +import moment from 'moment-timezone'; + import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import {renderWithIntl} from 'tests/react_testing_utils'; -import {OverActiveUserLimits} from 'utils/constants'; +import {OverActiveUserLimits, SelfHostedProducts} from 'utils/constants'; +import {TestHelper} from 'utils/test_helper'; import {General} from 'mattermost-redux/constants'; import {DeepPartial} from '@mattermost/types/utilities'; import {GlobalState} from '@mattermost/types/store'; import mockStore from 'tests/test_store'; +import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand'; + import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom') as typeof import('react-router-dom'), + useLocation: () => { + return { + pathname: '', + }; + }, +})); + describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => { const license = { IsLicensed: 'true', @@ -26,7 +40,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris SkuShortName: 'Enterprise', Name: 'LicenseName', Company: 'Mattermost Inc.', - Users: '1000000', + Users: '1000', }; const initialState: DeepPartial = { @@ -45,10 +59,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris }, general: { license, + config: { + BuildEnterpriseReady: 'true', + }, }, preferences: { myPreferences: {}, }, + admin: { + config: { + ServiceSettings: { + SelfHostedPurchase: true, + }, + }, + }, + cloud: { + subscription: undefined, + }, + hostedCustomer: { + products: { + products: { + prod_professional: TestHelper.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, + }), + }, + productsLoaded: true, + }, + }, }, }; @@ -80,12 +120,12 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris const item = wrapper.find('.item-element').filterWhere((n) => { return n.children().length === 2 && - n.childAt(0).type() === 'span' && - !n.childAt(0).text().includes('ACTIVE') && - n.childAt(0).text().includes('LICENSED SEATS'); + n.childAt(0).type() === 'span' && + !n.childAt(0).text().includes('ACTIVE') && + n.childAt(0).text().includes('LICENSED SEATS'); }); - expect(item.text()).toContain('1,000,000'); + expect(item.text()).toContain('1,000'); }); test('should not add any class if active users is lower than the minimal', async () => { @@ -146,4 +186,47 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris expect(screen.getByText('ACTIVE USERS:')).not.toHaveClass('legend--warning-over-seats-purchased'); expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--over-seats-purchased'); }); + + test('should add warning class to days expired indicator when there are more than 5 days until expiry', async () => { + license.ExpiresAt = moment().add(6, 'days').valueOf().toString(); + const store = await mockStore(initialState); + renderWithIntl( + + + , + ); + + expect(screen.getByText('Expires in 6 days')).toHaveClass('expiration-days-warning'); + }); + + test('should add danger class to days expired indicator when there are at least 5 days until expiry', async () => { + license.ExpiresAt = moment().add(5, 'days').valueOf().toString(); + const store = await mockStore(initialState); + renderWithIntl( + + + , + ); + + expect(screen.getByText('Expires in 5 days')).toHaveClass('expiration-days-danger'); + }); + + test('should display add seats button when there are more than 60 days until expiry and self hosted expansion is available', async () => { + license.ExpiresAt = moment().add(61, 'days').valueOf().toString(); + const store = await mockStore(initialState); + jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true); + renderWithIntl( + + + , + ); + + expect(screen.getByText('+ Add seats')).toBeVisible(); + }); }); diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index 790272b3a5..1a09207a64 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -4,6 +4,7 @@ import React, {RefObject, useEffect, useState} from 'react'; import classNames from 'classnames'; import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import Tag from 'components/widgets/tag/tag'; @@ -15,9 +16,17 @@ import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils'; import {FileTypes} from 'utils/constants'; import {getSkuDisplayName} from 'utils/subscription'; import {calculateOverageUserActivated} from 'utils/overage_team'; +import {getConfig} from 'mattermost-redux/selectors/entities/admin'; import './enterprise_edition.scss'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; +import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand'; +import {getExpandSeatsLink} from 'selectors/cloud'; +import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; +import {useQuery} from 'utils/http_utils'; + +const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; +const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; export interface EnterpriseEditionProps { openEELicenseModal: () => void; @@ -47,10 +56,23 @@ const EnterpriseEditionLeftPanel = ({ const {formatMessage} = useIntl(); const [unsanitizedLicense, setUnsanitizedLicense] = useState(license); const openPricingModal = useOpenPricingModal(); + const canExpand = useCanSelfHostedExpand(); + const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); + const expandableLink = useSelector(getExpandSeatsLink); + const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; + + const query = useQuery(); + const actionQueryParam = query.get('action'); + + useEffect(() => { + if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) { + selfHostedExpansionModal.open(); + query.set('action', ''); + } + }, []); useEffect(() => { async function fetchUnSanitizedLicense() { - // This solves this the issue reported here: https://mattermost.atlassian.net/browse/MM-42906 try { const unsanitizedL = await Client4.getClientLicenseOld(); setUnsanitizedLicense(unsanitizedL); @@ -77,6 +99,14 @@ const EnterpriseEditionLeftPanel = ({ ); + const handleClickAddSeats = () => { + if (!isSelfHostedPurchaseEnabled || !canExpand) { + window.open(expandableLink(unsanitizedLicense.Id), '_blank'); + } else { + selfHostedExpansionModal.open(); + } + }; + return (
{'License details'} - {(expirationDays <= 30) && - - {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} - + {canExpand && + }
{ @@ -134,6 +170,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) }
@@ -162,7 +199,7 @@ const EnterpriseEditionLeftPanel = ({ type LegendValues = 'START DATE:' | 'EXPIRES:' | 'LICENSED SEATS:' | 'ACTIVE USERS:' | 'EDITION:' | 'LICENSE ISSUED:' | 'NAME:' | 'COMPANY / ORG:' -const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => { +const renderLicenseValues = (activeUsers: number, seatsPurchased: number, expirationDays: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => { if (legend === 'ACTIVE USERS:') { const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased}); return ( @@ -186,6 +223,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l >{value} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +266,7 @@ const renderLicenseContent = ( fileInputRef: RefObject, handleChange: () => void, statsActiveUsers: number, + expirationDays: number, ) => { // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. @@ -246,7 +304,7 @@ const renderLicenseContent = ( return (
- {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))} + {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
{renderAddNewLicenseButton(fileInputRef, handleChange)} {renderRemoveButton(handleRemove, isDisabled, removing)} diff --git a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts new file mode 100644 index 0000000000..93f2d2092e --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import {Client4} from 'mattermost-redux/client'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; +import {BillingSchemes, SelfHostedProducts} from 'utils/constants'; + +import {isCloudLicense} from 'utils/license_utils'; + +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; + +import useGetSelfHostedProducts from './useGetSelfHostedProducts'; + +export default function useCanSelfHostedExpand() { + // NOTE: This is a basic implementation to get things up and running, more details to come later. + const [expansionAvailable, setExpansionAvailable] = useState(false); + const config = useSelector(getConfig); + const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; + const isSalesServeOnly = useSelector(getSubscriptionProduct)?.billing_scheme === BillingSchemes.SALES_SERVE; + const license = useSelector(getLicense); + const isCloud = isCloudLicense(license); + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + + // Self Hosted Products never contains a product for starter, additional check is done out of caution. + const isSelfHostedStarter = currentProduct === null || currentProduct?.sku === SelfHostedProducts.STARTER; + + useEffect(() => { + if (!isEnterpriseReady) { + return; + } + Client4.getLicenseSelfServeStatus(). + then((res) => { + setExpansionAvailable(res.is_expandable ?? false); + }). + catch(() => { + setExpansionAvailable(false); + }); + }, [isEnterpriseReady]); + + return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable; +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts new file mode 100644 index 0000000000..a310c4538e --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {trackEvent} from 'actions/telemetry_actions'; +import {openModal} from 'actions/views/modals'; +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import PurchaseInProgressModal from 'components/purchase_in_progress_modal'; +import {Client4} from 'mattermost-redux/client'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; + +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; +import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal'; + +import {useControlModal, ControlModal} from './useControlModal'; +import useCanSelfHostedExpand from './useCanSelfHostedExpand'; + +interface HookOptions{ + trackingLocation?: string; +} + +export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { + const dispatch = useDispatch(); + const currentUser = useSelector(getCurrentUser); + const canExpand = useCanSelfHostedExpand(); + const controlModal = useControlModal({ + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + dialogType: SelfHostedExpansionModal, + }); + + return useMemo(() => { + return { + ...controlModal, + open: async () => { + if (!canExpand) { + return; + } + + const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true'; + + // check if user already has an open purchase modal in current browser. + if (purchaseInProgress) { + // User within the same browser session + // is already trying to purchase. Notify them of this + // and request the exit that purchase flow before attempting again. + dispatch(openModal({ + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + dialogType: PurchaseInProgressModal, + dialogProps: { + purchaserEmail: currentUser.email, + storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, + }, + })); + return; + } + + trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', { + callerInfo: options.trackingLocation, + }); + + try { + const result = await Client4.bootstrapSelfHostedSignup(); + + if (result.email !== currentUser.email) { + // Token already exists and was created by another admin. + // Notify user of this and do not allow them to try to expand concurrently. + dispatch(openModal({ + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + dialogType: PurchaseInProgressModal, + dialogProps: { + purchaserEmail: result.email, + storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, + }, + })); + return; + } + + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: result.progress, + }); + + controlModal.open(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('error bootstrapping self hosted purchase modal', e); + } + }, + }; + }, [controlModal, options.trackingLocation]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..8de1c55a9d 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -7,8 +7,8 @@ import {useDispatch, useSelector} from 'react-redux'; import {trackEvent} from 'actions/telemetry_actions'; import {closeModal, openModal} from 'actions/views/modals'; import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; -import SelfHostedPurchaseModal from 'components/self_hosted_purchase_modal'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants'; +import SelfHostedPurchaseModal from 'components/self_hosted_purchases/self_hosted_purchase_modal'; +import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; import PurchaseInProgressModal from 'components/purchase_in_progress_modal'; import {Client4} from 'mattermost-redux/client'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; @@ -63,6 +63,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions): dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, + storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, }, })); return; @@ -86,6 +87,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions): dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: result.email, + storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, }, })); return; diff --git a/webapp/channels/src/components/outlined_input/index.tsx b/webapp/channels/src/components/outlined_input/index.tsx new file mode 100644 index 0000000000..5644bf958c --- /dev/null +++ b/webapp/channels/src/components/outlined_input/index.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {OutlinedInput as MUIOutlineInput, OutlinedInputProps} from '@mui/material'; + +/** + * A horizontal separator for use in menus. + * @example + * { @@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => { it('when purchaser and user emails are different, user is instructed to wait', () => { const stateOverride: DeepPartial = JSON.parse(JSON.stringify(initialState)); stateOverride.entities!.users!.currentUserId = 'otherUserId'; - renderWithIntlAndStore(
, stateOverride); + renderWithIntlAndStore( +
+ +
, stateOverride, + ); screen.getByText('@UserAdmin is currently attempting to purchase a paid license.'); }); it('when purchaser and user emails are same, allows user to reset purchase flow', () => { - renderWithIntlAndStore(
, initialState); + renderWithIntlAndStore( +
+ +
, initialState, + ); expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled(); screen.getByText('Reset purchase flow').click(); diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx index 1a7cf3be80..2e0483a401 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx @@ -13,13 +13,13 @@ import {Client4} from 'mattermost-redux/client'; import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants'; import './index.scss'; import {GlobalState} from '@mattermost/types/store'; interface Props { purchaserEmail: string; + storageKey: string; } export default function PurchaseInProgressModal(props: Props) { @@ -64,7 +64,7 @@ export default function PurchaseInProgressModal(props: Props) { ); genericModalProps.handleConfirm = () => { - localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS); + localStorage.removeItem(props.storageKey); Client4.bootstrapSelfHostedSignup(true); close(); }; diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/address.tsx b/webapp/channels/src/components/self_hosted_purchases/address.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/address.tsx rename to webapp/channels/src/components/self_hosted_purchases/address.tsx diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/constants.ts b/webapp/channels/src/components/self_hosted_purchases/constants.ts similarity index 71% rename from webapp/channels/src/components/self_hosted_purchase_modal/constants.ts rename to webapp/channels/src/components/self_hosted_purchases/constants.ts index 3415fbf14f..72b16f750a 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/constants.ts +++ b/webapp/channels/src/components/self_hosted_purchases/constants.ts @@ -2,3 +2,4 @@ // See LICENSE.txt for license information. export const STORAGE_KEY_PURCHASE_IN_PROGRESS = 'PURCHASE_IN_PROGRESS'; +export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS'; diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/contact_sales_link.tsx b/webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/contact_sales_link.tsx rename to webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss @@ -0,0 +1,3 @@ +.self_hosted_expansion_failed { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..e0ca48f22a --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx @@ -0,0 +1,80 @@ +// 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 {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; +import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; +import IconMessage from 'components/purchase_modal/icon_message'; + +import './error_page.scss'; + +interface Props { + canRetry: boolean; + tryAgain: () => void; +} + +export default function SelfHostedExpansionErrorPage(props: Props) { + const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); + + const formattedTitle = ( + + ); + + let formattedButtonText = ( + + ); + + if (!props.canRetry) { + formattedButtonText = ( + + ); + } + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..e6910940f0 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss @@ -0,0 +1,131 @@ +.SelfHostedExpansionRHSCard { + display: flex; + width: 280px; + flex-direction: column; + + &__Content { + padding: 24px; + border: 1px solid; + border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); + border-radius: 4px; + } + + &__RHSCardTitle { + display: block; + margin-bottom: 12px; + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-size: 14px; + font-weight: 600; + text-align: center; + text-transform: capitalize; + } + + .seatsInput { + width: 73px; + margin-left: auto; + font-size: 14px; + font-weight: 400; + + input[type="number"] { + text-align: right; + } + + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + } + } + + &__PlanDetails { + display: flex; + flex-direction: column; + text-align: center; + + .planName { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-size: 20px; + font-weight: 400; + text-transform: capitalize; + } + + .usage { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56); + font-size: 12px; + font-weight: 600; + + :first-child { + text-transform: uppercase; + } + } + } + + hr { + width: 90%; + height: 2px; + background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); + } + + &__seatInput, + &__cost_breakdown { + display: grid; + font-weight: 400; + gap: 10px; + grid-template-columns: repeat(2, 1fr); + + .costPerUser > span:first-child { + font-size: 14px; + } + + .costPerUser > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-size: 12px; + } + + .totalCostWarning { + width: 141px; + } + + .totalCostWarning > span:first-child { + color: var(--sys-denim-center-channel-text); + font-size: 14px; + font-weight: 700; + } + + .totalCostWarning > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-size: 12px; + } + + .costAmount { + width: 100%; + margin-right: 0; + margin-left: auto; + font-weight: 700; + } + } + + &__AddSeatsWarning { + display: block; + width: 100%; + height: 35px; + margin-bottom: 15px; + color: var(--dnd-indicator); + font-size: 12px; + font-weight: 600; + text-align: right; + } + + &__CompletePurchaseButton { + width: 100%; + margin-top: 10px; + margin-bottom: 10px; + border-radius: 4px; + } + + &__ChargedTodayDisclaimer { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-size: 12px; + font-weight: 400; + } +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..b6440d986b --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx @@ -0,0 +1,244 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; +import moment from 'moment-timezone'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; + +import WarningIcon from 'components/widgets/icons/fa_warning_icon'; +import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; +import ExternalLink from 'components/external_link'; +import {OutlinedInput} from 'components/outlined_input'; + +import {DocLinks} from 'utils/constants'; +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; + +import './expansion_card.scss'; + +const MONTHS_IN_YEAR = 12; +const MAX_TRANSACTION_VALUE = 1_000_000 - 1; + +interface Props { + canSubmit: boolean; + licensedSeats: number; + minimumSeats: number; + submit: () => void; + updateSeats: (seats: number) => void; +} + +export default function SelfHostedExpansionCard(props: Props) { + const intl = useIntl(); + const license = useSelector(getLicense); + const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY'); + const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY'); + const [additionalSeats, setAdditionalSeats] = useState(props.minimumSeats); + const [overMaxSeats, setOverMaxSeats] = useState(false); + const licenseExpiry = new Date(parseInt(license.ExpiresAt, 10)); + const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats) || additionalSeats < props.minimumSeats; + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + const costPerMonth = currentProduct?.price_per_seat || 0; + + const getMonthsUntilExpiry = () => { + const now = new Date(); + return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear())); + }; + + const getCostPerUser = () => { + const monthsUntilExpiry = getMonthsUntilExpiry(); + return costPerMonth * monthsUntilExpiry; + }; + + const getPaymentTotal = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthsUntilExpiry = getMonthsUntilExpiry(); + return additionalSeats * costPerMonth * monthsUntilExpiry; + }; + + // Finds the maximum number of additional seats that is possible, taking into account + // the stripe transaction limit. The maximum number of seats will follow the formula: + // (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat + const getMaximumAdditionalSeats = () => { + if (currentProduct === null) { + return 0; + } + + const currentPaymentPrice = costPerMonth * props.licensedSeats * 12; + const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; + const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12)); + return Math.max(0, remainingSeats); + }; + const maxAdditionalSeats = getMaximumAdditionalSeats(); + + const handleNewSeatsInputChange = (e: React.ChangeEvent) => { + const requestedSeats = parseInt(e.target.value, 10); + + if (!isNaN(requestedSeats) && requestedSeats <= 0) { + e.preventDefault(); + return; + } + + setOverMaxSeats(false); + + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; + setOverMaxSeats(overMaxAdditionalSeats); + + const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats; + setAdditionalSeats(finalSeatCount); + + props.updateSeats(finalSeatCount); + }; + + const formatCurrency = (value: number) => { + return intl.formatNumber(value, {style: 'currency', currency: 'USD'}); + }; + + return ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && isNaN(additionalSeats) && + , + }} + /> + } + {invalidAdditionalSeats && additionalSeats < props.minimumSeats && + , + minimumSeats: props.minimumSeats, + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } +
+
+
+ +
+ +
+
+ {formatCurrency(getCostPerUser())} +
+
+ +
+ +
+ + {formatCurrency(getPaymentTotal()) } + +
+ +
+ ( + <> +
+ + {text} + + + ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..2915d932de --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx @@ -0,0 +1,494 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {screen, fireEvent, waitFor} from '@testing-library/react'; + +import {GlobalState} from 'types/store'; + +import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; + +import {renderWithIntlAndStore} from 'tests/react_testing_utils'; +import {TestHelper as TH} from 'utils/test_helper'; +import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants'; + +import {DeepPartial} from '@mattermost/types/utilities'; + +import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './'; +import moment from 'moment-timezone'; + +interface MockCardInputProps { + onCardInputChange: (event: {complete: boolean}) => void; + forwardedRef: React.MutableRefObject; +} + +// number borrowed from stripe +const successCardNumber = '4242424242424242'; +function MockCardInput(props: MockCardInputProps) { + props.forwardedRef.current = { + getCard: () => ({}), + }; + return ( + ) => { + if (e.target.value === successCardNumber) { + props.onCardInputChange({complete: true}); + } + }} + /> + ); +} + +jest.mock('components/payment_form/card_input', () => { + const original = jest.requireActual('components/payment_form/card_input'); + return { + ...original, + __esModule: true, + default: MockCardInput, + }; +}); + +jest.mock('components/self_hosted_purchases/stripe_provider', () => { + return function(props: {children: React.ReactNode | React.ReactNodeArray}) { + return props.children; + }; +}); + +jest.mock('components/common/hooks/useLoadStripe', () => { + return function() { + return {current: { + stripe: {}, + + }}; + }; +}); + +const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT; +const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE; +const failOrg = 'failorg'; + +const existingUsers = 10; + +const mockProfessionalProduct = TH.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, + recurring_interval: RecurringIntervals.MONTH, +}); + +jest.mock('mattermost-redux/client', () => { + const original = jest.requireActual('mattermost-redux/client'); + return { + __esModule: true, + ...original, + Client4: { + ...original.Client4, + pageVisited: jest.fn(), + setAcceptLanguage: jest.fn(), + trackEvent: jest.fn(), + createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => { + if (form.organization === failOrg) { + throw new Error('error creating customer'); + } + return Promise.resolve({ + progress: mockCreatedIntent, + }); + }, + confirmSelfHostedExpansion: () => Promise.resolve({ + progress: mockCreatedLicense, + license: {Users: existingUsers * 2}, + }), + }, + }; +}); + +jest.mock('components/payment_form/stripe', () => { + const original = jest.requireActual('components/payment_form/stripe'); + return { + __esModule: true, + ...original, + getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}), + }; +}); + +jest.mock('utils/hosted_customer', () => { + const original = jest.requireActual('utils/hosted_customer'); + return { + __esModule: true, + ...original, + findSelfHostedProductBySku: () => { + return mockProfessionalProduct; + }, + }; +}); + +const productName = SelfHostedProducts.PROFESSIONAL; + +// Licensed expiry set as 3 months from the current date (rolls over to new years). +let licenseExpiry = moment(); +const monthsUntilLicenseExpiry = 3; +licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); + +const initialState: DeepPartial = { + views: { + modals: { + modalState: { + [ModalIdentifiers.SELF_HOSTED_EXPANSION]: { + open: true, + }, + }, + }, + }, + storage: { + storage: {}, + }, + entities: { + teams: { + currentTeamId: '', + }, + preferences: { + myPreferences: { + theme: {}, + }, + }, + general: { + config: { + EnableDeveloper: 'false', + }, + license: { + SkuName: productName, + Sku: productName, + Users: '50', + ExpiresAt: licenseExpiry.valueOf().toString(), + }, + }, + cloud: { + subscription: {}, + }, + users: { + currentUserId: 'adminUserId', + profiles: { + adminUserId: TH.getUserMock({ + id: 'adminUserId', + roles: 'admin', + first_name: 'first', + last_name: 'admin', + }), + otherUserId: TH.getUserMock({ + id: 'otherUserId', + roles: '', + first_name: '', + last_name: '', + }), + }, + filteredStats: { + total_users_count: 100, + }, + }, + hostedCustomer: { + products: { + productsLoaded: true, + products: { + prod_professional: mockProfessionalProduct, + }, + }, + signupProgress: SelfHostedSignupProgress.START, + }, + }, +}; + +const valueEvent = (value: any) => ({target: {value}}); +function changeByPlaceholder(sel: string, val: any) { + fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val)); +} + +function selectDropdownValue(testId: string, value: string) { + fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); + fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any); +} + +function changeByTestId(testId: string, value: string) { + fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); +} + +interface PurchaseForm { + card: string; + org: string; + name: string; + country: string; + address: string; + city: string; + state: string; + zip: string; + seats: string; + agree: boolean; +} + +const defaultSuccessForm: PurchaseForm = { + card: successCardNumber, + org: 'My org', + name: 'The Cardholder', + country: 'United States of America', + address: '123 Main Street', + city: 'Minneapolis', + state: 'MN', + zip: '55423', + seats: '50', + agree: true, +}; + +function fillForm(form: PurchaseForm) { + changeByPlaceholder('Card number', form.card); + changeByPlaceholder('Organization Name', form.org); + changeByPlaceholder('Name on Card', form.name); + selectDropdownValue('selfHostedExpansionCountrySelector', form.country); + changeByPlaceholder('Address', form.address); + changeByPlaceholder('City', form.city); + selectDropdownValue('selfHostedExpansionStateSelector', form.state); + changeByPlaceholder('Zip/Postal Code', form.zip); + if (form.agree) { + fireEvent.click(screen.getByText('I have read and agree', {exact: false})); + } + + const completeButton = screen.getByText('Complete purchase'); + + if (form === defaultSuccessForm) { + expect(completeButton).toBeEnabled(); + } + + return completeButton; +} + +describe('SelfHostedExpansionModal Open', () => { + it('renders the form', () => { + renderWithIntlAndStore(
, initialState); + + screen.getByText('Provide your payment details'); + screen.getByText('Add new seats'); + screen.getByText('Contact Sales'); + screen.getByText('Cost per user', {exact: false}); + + // screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}}); + screen.getByText('Your credit card will be charged today.'); + screen.getByText('See how billing works', {exact: false}); + }); + + it('filling the form enables expansion', () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + fillForm(defaultSuccessForm); + }); + + it('happy path submit shows success screen when confirmation succeeds', async () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + + const upgradeButton = fillForm(defaultSuccessForm); + upgradeButton.click(); + + expect(screen.findByText('The license has been automatically applied')).toBeTruthy(); + }); + + it('happy path submit shows submitting screen while requesting confirmation', async () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + + const upgradeButton = fillForm(defaultSuccessForm); + upgradeButton.click(); + + await waitFor(() => expect(document.getElementsByClassName('submitting')[0]).toBeTruthy(), {timeout: 1234}); + }); + + it('sad path submit shows error screen', async () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + fillForm(defaultSuccessForm); + changeByPlaceholder('Organization Name', failOrg); + + const upgradeButton = screen.getByText('Complete purchase'); + expect(upgradeButton).toBeEnabled(); + upgradeButton.click(); + await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234}); + }); +}); + +describe('SelfHostedExpansionModal RHS Card', () => { + it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => { + renderWithIntlAndStore(
, initialState); + + const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '1', 10); + + const seatsField = screen.getByTestId('seatsInput').querySelector('input'); + expect(seatsField).toBeInTheDocument(); + expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); + }); + + it('Seat input only allows users to fill input with the licensed seats and active users difference if it is not 0', () => { + const expectedUserOverage = '50'; + + renderWithIntlAndStore(
, initialState); + fillForm(defaultSuccessForm); + + // The seat input should already have the expected value. + expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage); + + // Try to set an undefined value. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); + + // Expecting the seats input to now contain the difference between active users and licensed seats. + expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage); + expect(screen.getByText('Complete purchase')).toBeEnabled(); + }); + + it('New seats input cannot be less than 1', () => { + if (initialState.entities?.users?.filteredStats?.total_users_count) { + initialState.entities.users.filteredStats.total_users_count = 50; + } + + const expectedAddNewSeats = '1'; + + renderWithIntlAndStore(
, initialState); + fillForm(defaultSuccessForm); + + // Try to set a negative value. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, -10); + expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats); + + // Try to set a 0 value. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, 0); + expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats); + }); + + it('Cost per User should be represented as the current subscription price multiplied by the remaining months', () => { + renderWithIntlAndStore(
, initialState); + + const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat; + + const costPerUser = document.getElementsByClassName('costPerUser')[0]; + expect(costPerUser).toBeInTheDocument(); + expect(costPerUser.innerHTML).toContain('Cost per user
$' + mockProfessionalProduct.price_per_seat.toFixed(2) + ' x ' + monthsUntilLicenseExpiry + ' months'); + + const costAmount = document.getElementsByClassName('costAmount')[0]; + expect(costAmount).toBeInTheDocument(); + expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser); + }); + + it('Total cost User should be represented as the current subscription price multiplied by the remaining months multiplied by the number of users', () => { + renderWithIntlAndStore(
, initialState); + const seatsInputValue = 100; + changeByTestId('seatsInput', seatsInputValue.toString()); + + const expectedTotalCost = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat * seatsInputValue; + + const costAmount = document.getElementsByClassName('totalCostAmount')[0]; + expect(costAmount).toBeInTheDocument(); + expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost)); + }); +}); + +describe('SelfHostedExpansionModal Submit', () => { + function makeHappyPathState(): FormState { + return { + address: 'string', + address2: 'string', + city: 'string', + state: 'string', + country: 'string', + postalCode: '12345', + shippingAddress: 'string', + shippingAddress2: 'string', + shippingCity: 'string', + shippingState: 'string', + shippingCountry: 'string', + shippingPostalCode: '12345', + shippingSame: false, + agreedTerms: true, + cardName: 'string', + organization: 'string', + cardFilled: true, + seats: 1, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; + } + it('if submitting, can not submit again', () => { + const state = makeHappyPathState(); + state.submitting = true; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false); + }); + + it('if created license, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true); + }); + + it('if paid, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); + }); + + it('if created subscription, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true); + }); + + it('if all details filled and card has not been confirmed, can submit', () => { + const state = makeHappyPathState(); + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true); + }); + + it('if card name missing and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.cardName = ''; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if card number missing and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.cardFilled = false; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if address not filled and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.address = ''; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if seats not valid and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.seats = 0; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if card confirmed, card not required for submission', () => { + const state = makeHappyPathState(); + state.cardFilled = false; + state.cardName = ''; + expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true); + }); + + it('if passed unknown progress status, can not submit', () => { + const state = makeHappyPathState(); + expect(canSubmit(state, 'unknown status' as any)).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..b66a81f517 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -0,0 +1,534 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import classNames from 'classnames'; + +import {StripeCardElementChangeEvent} from '@stripe/stripe-js'; + +import {getLicenseConfig} from 'mattermost-redux/actions/general'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users'; +import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; +import {Client4} from 'mattermost-redux/client'; +import {isDevModeEnabled} from 'selectors/general'; + +import {closeModal} from 'actions/views/modals'; +import {pageVisited} from 'actions/telemetry_actions'; +import {confirmSelfHostedExpansion} from 'actions/hosted_customer'; + +import {ValueOf} from '@mattermost/types/utilities'; +import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; + +import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; +import RootPortal from 'components/root_portal'; +import ContactSalesLink from 'components/self_hosted_purchases/contact_sales_link'; +import ErrorPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/error_page'; +import SuccessPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/success_page'; +import useLoadStripe from 'components/common/hooks/useLoadStripe'; +import CardInput, {CardInputType} from 'components/payment_form/card_input'; +import FullScreenModal from 'components/widgets/modals/full_screen_modal'; +import Input from 'components/widgets/inputs/input/input'; +import BackgroundSvg from 'components/common/svg_images_components/background_svg'; +import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms'; +import Address from 'components/self_hosted_purchases/address'; +import ChooseDifferentShipping from 'components/choose_different_shipping'; + +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import {inferNames} from 'utils/hosted_customer'; + +import Submitting from './submitting'; +import StripeProvider from '../stripe_provider'; +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants'; +import SelfHostedExpansionCard from './expansion_card'; +import './self_hosted_expansion_modal.scss'; + +export interface FormState { + cardName: string; + cardFilled: boolean; + + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + organization: string; + + seats: number; + + shippingSame: boolean; + shippingAddress: string; + shippingAddress2: string; + shippingCity: string; + shippingState: string; + shippingCountry: string; + shippingPostalCode: string; + + agreedTerms: boolean; + + submitting: boolean; + succeeded: boolean; + progressBar: number; + error: string; +} + +export function makeInitialState(seats: number): FormState { + return { + cardName: '', + cardFilled: false, + address: '', + address2: '', + city: '', + state: '', + country: '', + postalCode: '', + organization: '', + shippingSame: true, + shippingAddress: '', + shippingAddress2: '', + shippingCity: '', + shippingState: '', + shippingCountry: '', + shippingPostalCode: '', + seats, + agreedTerms: false, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; +} + +export function canSubmit(formState: FormState, progress: ValueOf) { + if (formState.submitting) { + return false; + } + + const validAddress = Boolean( + formState.organization && + formState.address && + formState.city && + formState.state && + formState.postalCode && + formState.country, + ); + + const validShippingAddress = Boolean( + formState.shippingSame || + (formState.shippingAddress && + formState.shippingCity && + formState.shippingState && + formState.shippingPostalCode && + formState.shippingCountry), + ); + + const agreedToTerms = formState.agreedTerms; + + const validCard = Boolean( + formState.cardName && + formState.cardFilled, + ); + const validSeats = formState.seats > 0; + + switch (progress) { + case SelfHostedSignupProgress.PAID: + case SelfHostedSignupProgress.CREATED_LICENSE: + case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: + return true; + case SelfHostedSignupProgress.CONFIRMED_INTENT: { + return Boolean( + validAddress && validShippingAddress && validSeats && agreedToTerms, + ); + } + case SelfHostedSignupProgress.START: + case SelfHostedSignupProgress.CREATED_CUSTOMER: + case SelfHostedSignupProgress.CREATED_INTENT: + return Boolean( + validCard && + validAddress && + validShippingAddress && + validSeats && + agreedToTerms, + ); + default: { + return false; + } + } +} + +export default function SelfHostedExpansionModal() { + const dispatch = useDispatch(); + const intl = useIntl(); + const cardRef = useRef(null); + const theme = useSelector(getTheme); + const progress = useSelector(getSelfHostedSignupProgress); + const user = useSelector(getCurrentUser); + const isDevMode = useSelector(isDevModeEnabled); + + const license = useSelector(getLicense); + const licensedSeats = parseInt(license.Users, 10); + const currentPlan = license.SkuName; + const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; + const [minimumSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); + const [requestedSeats, setRequestedSeats] = useState(minimumSeats); + + const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); + const stripeRef = useLoadStripe(stripeLoadHint); + + const initialState = makeInitialState(requestedSeats); + const [formState, setFormState] = useState(initialState); + const [show] = useState(true); + const canRetry = formState.error !== '422'; + const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !formState.submitting && !formState.error && !formState.succeeded; + + const title = intl.formatMessage({ + id: 'self_hosted_expansion.expansion_modal.title', + defaultMessage: 'Provide your payment details', + }); + + const canSubmitForm = canSubmit(formState, progress); + + const submit = async () => { + let submitProgress = progress; + let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null; + setFormState({...formState, submitting: true}); + try { + const [firstName, lastName] = inferNames(user, formState.cardName); + + signupCustomerResult = await Client4.createCustomerSelfHostedSignup({ + first_name: firstName, + last_name: lastName, + billing_address: { + city: formState.city, + country: formState.country, + line1: formState.address, + line2: formState.address2, + postal_code: formState.postalCode, + state: formState.state, + }, + shipping_address: { + city: formState.city, + country: formState.country, + line1: formState.address, + line2: formState.address2, + postal_code: formState.postalCode, + state: formState.state, + }, + organization: formState.organization, + }); + } catch { + setFormState({...formState, error: 'Failed to submit payment information'}); + return; + } + + if (signupCustomerResult === null) { + setStripeLoadHint(Math.random()); + setFormState({...formState, submitting: false}); + return; + } + + if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: signupCustomerResult.progress, + }); + submitProgress = signupCustomerResult.progress; + } + if (stripeRef.current === null) { + setStripeLoadHint(Math.random()); + setFormState({...formState, submitting: false}); + return; + } + + try { + const card = cardRef.current?.getCard(); + if (!card) { + const message = 'Failed to get card when it was expected'; + setFormState({...formState, error: message}); + return; + } + const finished = await dispatch(confirmSelfHostedExpansion( + stripeRef.current, + { + id: signupCustomerResult.setup_intent_id, + client_secret: signupCustomerResult.setup_intent_secret, + }, + isDevMode, + { + address: formState.address, + address2: formState.address2, + city: formState.city, + state: formState.state, + country: formState.country, + postalCode: formState.postalCode, + name: formState.cardName, + card, + }, + submitProgress, + { + seats: formState.seats, + license_id: license.Id, + }, + )); + + if (finished.data) { + setFormState({...formState, succeeded: true}); + + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CREATED_LICENSE, + }); + + // Reload license in background. + // Needed if this was completed while on the Edition and License page. + dispatch(getLicenseConfig()); + } else if (finished.error) { + let errorData = finished.error; + if (finished.error === 422) { + errorData = finished.error.toString(); + } + setFormState({...formState, error: errorData}); + return; + } + setFormState({...formState, submitting: false}); + } catch (e) { + setFormState({...formState, error: 'unable to complete signup'}); + } + }; + + useEffect(() => { + pageVisited( + TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, + 'pageview_self_hosted_expansion', + ); + + localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true'); + return () => { + localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS); + }; + }, []); + + const resetToken = () => { + try { + Client4.bootstrapSelfHostedSignup(true). + then((data) => { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: data.progress, + }); + }); + } catch { + // swallow error ok here + } + }; + + return ( + + + { + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + resetToken(); + }} + > +
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
+
+ + {intl.formatMessage({ + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} + +
+ { + setFormState({...formState, cardFilled: event.complete}); + }} + theme={theme} + /> +
+
+ ) => { + setFormState({...formState, organization: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'self_hosted_signup.organization', + defaultMessage: 'Organization Name', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + /> +
+ + + +
{ + setFormState({...formState, country: option.value}); + }} + address={formState.address} + changeAddress={(e) => { + setFormState({...formState, address: e.target.value}); + }} + address2={formState.address2} + changeAddress2={(e) => { + setFormState({...formState, address2: e.target.value}); + }} + city={formState.city} + changeCity={(e) => { + setFormState({...formState, city: e.target.value}); + }} + state={formState.state} + changeState={(state: string) => { + setFormState({...formState, state}); + }} + postalCode={formState.postalCode} + changePostalCode={(e) => { + setFormState({...formState, postalCode: e.target.value}); + }} + /> + { + setFormState({...formState, shippingSame: val}); + }} + /> + {!formState.shippingSame && ( + <> +
+ +
+
{ + setFormState({...formState, shippingCountry: option.value}); + }} + address={formState.shippingAddress} + changeAddress={(e) => { + setFormState({...formState, shippingAddress: e.target.value}); + }} + address2={formState.shippingAddress2} + changeAddress2={(e) => { + setFormState({...formState, shippingAddress2: e.target.value}); + }} + city={formState.shippingCity} + changeCity={(e) => { + setFormState({...formState, shippingCity: e.target.value}); + }} + state={formState.shippingState} + changeState={(state: string) => { + setFormState({...formState, shippingState: state}); + }} + postalCode={formState.shippingPostalCode} + changePostalCode={(e) => { + setFormState({...formState, shippingPostalCode: e.target.value}); + }} + /> + + )} + { + setFormState({...formState, agreedTerms: data}); + }} + /> +
+
+
+ { + setFormState({...formState, seats}); + setRequestedSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + minimumSeats={minimumSeats} + /> +
+
+ {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( + { + setFormState({...formState, submitting: false, error: '', succeeded: false}); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + }} + /> + )} + {formState.submitting && ( + + )} + {formState.error && ( + { + setFormState({...formState, submitting: false, error: ''}); + }} + /> + )} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..1938890b37 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -0,0 +1,183 @@ +.SelfHostedExpansionModal { + height: 100%; + + .form-view { + display: flex; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: top; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; + overflow-x: hidden; + + .title { + font-size: 22px; + font-weight: 600; + } + + .form { + padding: 0 96px; + margin: 0 auto; + + .form-row { + display: flex; + width: 100%; + margin-bottom: 24px; + } + + .form-row-third-1 { + width: 66%; + max-width: 288px; + margin-right: 16px; + + .DropdownInput { + margin-top: 0; + } + } + + .DropdownInput { + position: relative; + height: 36px; + margin-bottom: 24px; + + .Input_fieldset { + height: 43px; + } + } + + .form-row-third-2 { + width: 34%; + max-width: 144px; + } + + .section-title { + display: block; + margin-bottom: 10px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 16px; + font-weight: 600; + text-align: left; + } + + .Input_fieldset { + height: 40px; + padding: 2px 1px; + background: var(--center-channel-bg); + + .Input { + background: inherit; + } + + .Input_wrapper { + margin: 0; + } + } + } + + &--hide { + display: none; + } + + >.lhs { + width: 25%; + } + + >.center { + width: 50%; + } + + >.rhs { + position: sticky; + display: flex; + width: 25%; + flex-direction: column; + align-items: center; + } + + .submitting, + .success, + .failed { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: center; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; + + .IconMessage .content .IconMessage-link { + margin-left: 0; + } + } + + .background-svg { + position: absolute; + z-index: -1; + top: 0; + width: 100%; + height: 100%; + + >div { + position: absolute; + top: 0; + left: 0; + } + } + + .self-hosted-agreed-terms { + label { + display: flex; + align-items: flex-start; + justify-content: flex-start; + } + + input[type=checkbox] { + width: 17px; + height: 17px; + flex-shrink: 0; + margin-right: 12px; + } + + font-size: 16px; + } + } + + @media (max-width: 1020px) { + .SelfHostedExpansionModal { + .form-view { + >.lhs { + display: none; + } + + >.center { + width: 66%; + } + + >.rhs { + width: 33%; + } + } + } + } + + .FullScreenModal { + .close-x { + top: 12px; + right: 12px; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx new file mode 100644 index 0000000000..7eafc6c9b1 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; + +import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {ValueOf} from '@mattermost/types/utilities'; + +import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; +import IconMessage from 'components/purchase_modal/icon_message'; + +import './submitting_page.scss'; + +function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { + const intl = useIntl(); + switch (progress) { + case SelfHostedSignupProgress.START: + case SelfHostedSignupProgress.CREATED_CUSTOMER: + case SelfHostedSignupProgress.CREATED_INTENT: + return intl.formatMessage({ + id: 'self_hosted_signup.progress_step.submitting_payment', + defaultMessage: 'Submitting payment information', + }); + case SelfHostedSignupProgress.CONFIRMED_INTENT: + case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: + return intl.formatMessage({ + id: 'self_hosted_signup.progress_step.verifying_payment', + defaultMessage: 'Verifying payment details', + }); + case SelfHostedSignupProgress.PAID: + case SelfHostedSignupProgress.CREATED_LICENSE: + return intl.formatMessage({ + id: 'self_hosted_signup.progress_step.applying_license', + defaultMessage: 'Applying your {planName} license to your Mattermost instance', + }, {planName}); + default: + return intl.formatMessage({ + id: 'self_hosted_signup.progress_step.submitting_payment', + defaultMessage: 'Submitting payment information', + }); + } +} + +export function convertProgressToBar(progress: ValueOf): number { + switch (progress) { + case SelfHostedSignupProgress.START: + return 15; + case SelfHostedSignupProgress.CREATED_CUSTOMER: + return 30; + case SelfHostedSignupProgress.CREATED_INTENT: + return 45; + case SelfHostedSignupProgress.CONFIRMED_INTENT: + return 60; + case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: + return 75; + case SelfHostedSignupProgress.PAID: + return 85; + case SelfHostedSignupProgress.CREATED_LICENSE: + return 100; + default: + return 0; + } +} + +interface Props { + currentPlan: string; +} + +const maxProgressBar = 100; +const maxFakeProgressIncrement = 5; +const fakeProgressInterval = 600; + +export default function Submitting(props: Props) { + const [barProgress, setBarProgress] = useState(0); + const signupProgress = useSelector(getSelfHostedSignupProgress); + const waitingExplanation = useConvertProgressToWaitingExplanation(signupProgress, props.currentPlan); + const footer = ( +
+
+
+ ); + + useEffect(() => { + const maxProgressForCurrentSignupProgress = convertProgressToBar(signupProgress); + const interval = setInterval(() => { + if (barProgress < maxProgressBar) { + setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement)); + } + }, fakeProgressInterval); + + return () => clearInterval(interval); + }, [barProgress]); + + return ( + +
+ + )} + formattedSubtitle={waitingExplanation} + icon={ + + } + footer={footer} + className={'processing'} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss new file mode 100644 index 0000000000..46179472b8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss @@ -0,0 +1,7 @@ +.submitting { + overflow: hidden; + + .processing { + margin-top: 163px; + } +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..522384347e --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss @@ -0,0 +1,23 @@ +.SelfHostedPurchaseModal__success { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: center; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-size: 16px; + font-weight: 600; +} + +.self_hosted_expansion_success { + overflow: hidden; + + .selfHostedExpansionModal__success { + margin-top: 163px; + } +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..f8c362780c --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx @@ -0,0 +1,76 @@ +// 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 {useHistory} from 'react-router-dom'; + +import IconMessage from 'components/purchase_modal/icon_message'; +import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; + +import {ConsolePages} from 'utils/constants'; + +import './success_page.scss'; + +interface Props { + onClose: () => void; +} + +export default function SelfHostedExpansionSuccessPage(props: Props) { + const history = useHistory(); + const titleText = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + { + history.push(ConsolePages.BILLING_HISTORY); + props.onClose(); + }} + > + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ +
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/error.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/error.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx similarity index 99% rename from webapp/channels/src/components/self_hosted_purchase_modal/index.test.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx index c8d568206b..369fa23f49 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx @@ -15,7 +15,7 @@ import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants'; import {DeepPartial} from '@mattermost/types/utilities'; -import SelfHostedPurchaseModal, {makeInitialState, canSubmit, State} from './'; +import SelfHostedPurchaseModal, {makeInitialState, canSubmit, State} from '.'; interface MockCardInputProps { onCardInputChange: (event: {complete: boolean}) => void; @@ -50,7 +50,7 @@ jest.mock('components/payment_form/card_input', () => { }; }); -jest.mock('components/self_hosted_purchase_modal/stripe_provider', () => { +jest.mock('components/self_hosted_purchases/stripe_provider', () => { return function(props: {children: React.ReactNode | React.ReactNodeArray}) { return props.children; }; diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx similarity index 97% rename from webapp/channels/src/components/self_hosted_purchase_modal/index.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx index af43bfd229..b2f9326cca 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx @@ -26,6 +26,8 @@ import {GlobalState} from 'types/store'; import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; +import {inferNames} from 'utils/hosted_customer'; + import { ModalIdentifiers, StatTypes, @@ -46,29 +48,28 @@ import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardA import ChooseDifferentShipping from 'components/choose_different_shipping'; import {ValueOf} from '@mattermost/types/utilities'; -import {UserProfile} from '@mattermost/types/users'; import { SelfHostedSignupProgress, SelfHostedSignupCustomerResponse, } from '@mattermost/types/hosted_customer'; -import {Seats, errorInvalidNumber} from '../seats_calculator'; +import {Seats, errorInvalidNumber} from '../../seats_calculator'; -import ContactSalesLink from './contact_sales_link'; +import ContactSalesLink from '../contact_sales_link'; import Submitting, {convertProgressToBar} from './submitting'; import ErrorPage from './error'; import SuccessPage from './success_page'; import SelfHostedCard from './self_hosted_card'; -import StripeProvider from './stripe_provider'; +import StripeProvider from '../stripe_provider'; import Terms from './terms'; -import Address from './address'; +import Address from '../address'; import useNoEscape from './useNoEscape'; import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; +import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from '../constants'; export interface State { @@ -309,17 +310,6 @@ interface FakeProgress { intervalId?: NodeJS.Timeout; } -function inferNames(user: UserProfile, cardName: string): [string, string] { - if (user.first_name) { - return [user.first_name, user.last_name]; - } - const names = cardName.split(' '); - if (cardName.length === 2) { - return [names[0], names[1]]; - } - return [names[0], names.slice(1).join(' ')]; -} - export default function SelfHostedPurchaseModal(props: Props) { useFetchStandardAnalytics(); useNoEscape(); diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/self_hosted_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx similarity index 97% rename from webapp/channels/src/components/self_hosted_purchase_modal/self_hosted_card.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx index 13b1925be2..f528066acf 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/self_hosted_card.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx @@ -17,8 +17,8 @@ import { SelfHostedProducts, } from 'utils/constants'; -import Consequences from '../seats_calculator/consequences'; -import SeatsCalculator, {Seats} from '../seats_calculator'; +import Consequences from '../../seats_calculator/consequences'; +import SeatsCalculator, {Seats} from '../../seats_calculator'; // Card has a bunch of props needed for monthly/yearly payments that // do not apply to self-hosted. diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/self_hosted_purchase_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/self_hosted_purchase_modal.scss rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/submitting.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/success_page.scss rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/success_page.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/terms.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/terms.tsx rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/types.ts b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/types.ts rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/useNoEscape.ts b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/useNoEscape.ts rename to webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/stripe_provider.tsx b/webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx similarity index 100% rename from webapp/channels/src/components/self_hosted_purchase_modal/stripe_provider.tsx rename to webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 4d1aee8c3f..c5c1720d94 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1323,6 +1323,7 @@ "admin.license.enterprise.upgrade.eeLicenseLink": "Enterprise Edition License", "admin.license.enterprise.upgrading": "Upgrading {percentage}%", "admin.license.enterpriseEdition": "Enterprise Edition", + "admin.license.enterpriseEdition.add.seats": "+ Add seats", "admin.license.enterpriseEdition.subtitle": "This is an Enterprise Edition for the Mattermost {skuName} plan", "admin.license.enterprisePlanSubtitle": "We’re here to work with you and your needs. Contact us today to get more seats on your plan.", "admin.license.enterprisePlanTitle": "Need to increase your headcount?", @@ -4769,6 +4770,26 @@ "select_team.icon": "Select Team Icon", "select_team.join.icon": "Join Team Icon", "select_team.private.icon": "Private Team", + "self_hosted_expansion_rhs_card_add_new_seats": "Add new seats", + "self_hosted_expansion_rhs_card_cost_per_user_breakdown": "{costPerUser} x {monthsUntilExpiry} months", + "self_hosted_expansion_rhs_card_cost_per_user_title": "Cost per user", + "self_hosted_expansion_rhs_card_license_date": "{startsAt} - {endsAt}", + "self_hosted_expansion_rhs_card_licensed_seats": "{licensedSeats} LICENSES SEATS", + "self_hosted_expansion_rhs_card_maximum_seats_warning": "{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats", + "self_hosted_expansion_rhs_card_must_add_seats_warning": "{warningIcon} You must add a seat to continue", + "self_hosted_expansion_rhs_card_must_purchase_enough_seats": "{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license", + "self_hosted_expansion_rhs_card_total_prorated_warning": "The total will be prorated", + "self_hosted_expansion_rhs_card_total_title": "Total", + "self_hosted_expansion_rhs_complete_button": "Complete purchase", + "self_hosted_expansion_rhs_credit_card_charge_today_warning": "Your credit card will be charged today.See how billing works.", + "self_hosted_expansion_rhs_license_summary_title": "License Summary", + "self_hosted_expansion.close": "Close", + "self_hosted_expansion.contact_support": "Contact Support", + "self_hosted_expansion.expand_success": "You've successfully updated your license seat count", + "self_hosted_expansion.expansion_modal.title": "Provide your payment details", + "self_hosted_expansion.license_applied": "The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the Billing section of the system console.", + "self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.", + "self_hosted_expansion.try_again": "Try again", "self_hosted_signup.air_gapped_content": "It appears that your instance is air-gapped, or it may not be connected to the internet. To purchase a license, please visit", "self_hosted_signup.air_gapped_title": "Purchase through the customer portal", "self_hosted_signup.close": "Close", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 91b82df939..652e102586 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -464,6 +464,8 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', + EXPANSION_IN_PROGRESS: 'expansion_in_progress', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', }; @@ -751,6 +753,7 @@ export const TELEMETRY_CATEGORIES = { CLOUD_PURCHASING: 'cloud_purchasing', CLOUD_PRICING: 'cloud_pricing', SELF_HOSTED_PURCHASING: 'self_hosted_purchasing', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', CLOUD_ADMIN: 'cloud_admin', CLOUD_DELINQUENCY: 'cloud_delinquency', SELF_HOSTED_ADMIN: 'self_hosted_admin', @@ -1104,6 +1107,7 @@ export const DocLinks = { ONBOARD_LDAP: 'https://docs.mattermost.com/onboard/ad-ldap.html', ONBOARD_SSO: 'https://docs.mattermost.com/onboard/sso-saml.html', TRUE_UP_REVIEW: 'https://mattermost.com/pl/true-up-documentation', + SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url', }; @@ -2017,6 +2021,7 @@ export const ConsolePages = { WEB_SERVER: '/admin_console/environment/web_server', PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server', SMTP: '/admin_console/environment/smtp', + BILLING_HISTORY: '/admin_console/billing/billing_history', }; export const WindowSizes = { diff --git a/webapp/channels/src/utils/hosted_customer.ts b/webapp/channels/src/utils/hosted_customer.ts index 130bba706c..6ea29269f7 100644 --- a/webapp/channels/src/utils/hosted_customer.ts +++ b/webapp/channels/src/utils/hosted_customer.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {Product} from '@mattermost/types/cloud'; +import {UserProfile} from '@mattermost/types/users'; // find a self-hosted product based on its SKU // This function should not be used for cloud products, because there are @@ -17,3 +18,13 @@ export const findSelfHostedProductBySku = (products: Record, sk return matches[0]; }; +export const inferNames = (user: UserProfile, cardName: string): [string, string] => { + if (user.first_name) { + return [user.first_name, user.last_name]; + } + const names = cardName.split(' '); + if (cardName.length === 2) { + return [names[0], names[1]]; + } + return [names[0], names.slice(1).join(' ')]; +}; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 1f8638ab53..47e6741b0e 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -33,6 +33,7 @@ import { SelfHostedSignupCustomerResponse, SelfHostedSignupSuccessResponse, SelfHostedSignupBootstrapResponse, + SelfHostedExpansionRequest, } from '@mattermost/types/hosted_customer'; import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories'; @@ -3895,6 +3896,14 @@ export default class Client4 { ); }; + + confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { + return this.doFetch( + `${this.getHostedCustomerRoute()}/confirm-expand`, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, + ); + } + subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => { return this.doFetch( `${this.getHostedCustomerRoute()}/subscribe-newsletter`, diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index e8d3d6aa7f..dfd5e21127 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -370,7 +370,6 @@ export type ServiceSettings = { EnableCustomGroups: boolean; SelfHostedPurchase: boolean; AllowSyncedDrafts: boolean; - SelfHostedExpansion: boolean; }; export type TeamSettings = { diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index fcd5b4e70b..8b3a7099f7 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -75,3 +75,8 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export type SelfHostedExpansionRequest = { + seats: number; + license_id: string; +}