Этот коммит содержится в:
Conor Macpherson
2023-03-24 14:48:47 -04:00
родитель 379dbb1ca8
Коммит b40cd113fa
24 изменённых файлов: 2111 добавлений и 35 удалений

Просмотреть файл

@@ -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<typeof SelfHostedSignupProgress>,
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};
};
}

Просмотреть файл

@@ -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 {
@@ -194,4 +209,4 @@
}
}
}
}
}

Просмотреть файл

@@ -6,15 +6,20 @@ 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';
describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => {
@@ -26,7 +31,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
SkuShortName: 'Enterprise',
Name: 'LicenseName',
Company: 'Mattermost Inc.',
Users: '1000000',
Users: '1000',
};
const initialState: DeepPartial<GlobalState> = {
@@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
},
general: {
license,
config: {
BuildEnterpriseReady: 'true',
},
},
preferences: {
myPreferences: {},
},
admin: {
config: {
ServiceSettings: {
SelfHostedExpansion: 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 +111,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('USERS');
n.childAt(0).type() === 'span' &&
!n.childAt(0).text().includes('ACTIVE') &&
n.childAt(0).text().includes('USERS');
});
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 +177,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(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
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(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
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(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
expect(screen.getByText('+ Add seats')).toBeVisible();
});
});

Просмотреть файл

@@ -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,16 @@ 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';
const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30;
const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5;
export interface EnterpriseEditionProps {
openEELicenseModal: () => void;
@@ -47,10 +55,12 @@ 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);
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);
@@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({
const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true');
const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10));
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion;
const viewPlansButton = (
<button
@@ -77,6 +88,14 @@ const EnterpriseEditionLeftPanel = ({
</button>
);
const handleClickAddSeats = () => {
if (!isSelfHostedExpansionEnabled && !canExpand) {
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
} else {
selfHostedExpansionModal.open();
}
};
return (
<div
className='EnterpriseEditionLeftPanel'
@@ -117,10 +136,16 @@ const EnterpriseEditionLeftPanel = ({
<div className='licenseInformation'>
<div className='license-details-top'>
<span className='title'>{'License details'}</span>
{(expirationDays <= 30) &&
<span className='expiration-days'>
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
</span>
{canExpand &&
<button
className='add-seats-button btn btn-primary'
onClick={handleClickAddSeats}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
}
</div>
{
@@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({
fileInputRef,
handleChange,
statsActiveUsers,
expirationDays,
)
}
</div>
@@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({
type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | '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 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l
>{value}</span>
</div>
);
} else if (legend === 'EXPIRES:') {
return (
<div
className='item-element'
key={value + index.toString()}
>
<span className='legend'>{legend}</span>
<span className='value'>{value}</span>
{(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) &&
<span
className={classNames('expiration-days', {
'expiration-days-warning': expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD,
'expiration-days-danger': expirationDays <= DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD,
})}
>
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
</span>
}
</div>
);
}
return (
@@ -209,6 +255,7 @@ const renderLicenseContent = (
fileInputRef: RefObject<HTMLInputElement>,
handleChange: () => void,
statsActiveUsers: number,
expirationDays: number,
) => {
// Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English.
@@ -246,7 +293,7 @@ const renderLicenseContent = (
return (
<div className='licenseElements'>
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))}
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
<hr/>
{renderAddNewLicenseButton(fileInputRef, handleChange)}
{renderRemoveButton(handleRemove, isDisabled, removing)}

Просмотреть файл

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

Просмотреть файл

@@ -0,0 +1,93 @@
// 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_expansion_modal/constants';
import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal';
import {useControlModal, ControlModal} from './useControlModal';
interface HookOptions{
onClick?: () => void;
trackingLocation: string;
}
export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const controlModal = useControlModal({
modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION,
dialogType: SelfHostedExpansionModal,
});
return useMemo(() => {
return {
...controlModal,
open: async () => {
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.PURCHASE_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,
});
if (options.onClick) {
options.onClick();
}
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.onClick, options.trackingLocation]);
}

Просмотреть файл

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

Просмотреть файл

@@ -11,6 +11,8 @@ import {GlobalState} from 'types/store';
import {TestHelper as TH} from 'utils/test_helper';
import {Client4} from 'mattermost-redux/client';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
import PurchaseInProgressModal from './';
jest.mock('mattermost-redux/client', () => {
@@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => {
it('when purchaser and user emails are different, user is instructed to wait', () => {
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
stateOverride.entities!.users!.currentUserId = 'otherUserId';
renderWithIntlAndStore(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, stateOverride);
renderWithIntlAndStore(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, 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(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, initialState);
renderWithIntlAndStore(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, initialState,
);
expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled();
screen.getByText('Reset purchase flow').click();

Просмотреть файл

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

Просмотреть файл

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS';

Просмотреть файл

@@ -0,0 +1,3 @@
.self_hosted_expansion_failed {
margin-top: 163px;
}

Просмотреть файл

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import './error_page.scss';
export default function SelfHostedExpansionErrorPage() {
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'
defaultMessage='Sorry, the payment verification failed'
/>
);
const formattedButtonText = (
<FormattedMessage
id='error_modal.try_again'
defaultMessage='Try again'
/>
);
const formattedSubtitle = (
<FormattedMessage
id='self_hosted_expansion.paymentFailed'
defaultMessage='Payment failed. Please try again or contact support.'
/>
);
const tertiaryButtonText = (
<FormattedMessage
id='self_hosted_expansion.contact_support'
defaultMessage={'Contact Support'}
/>
);
const icon = (
<PaymentFailedSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_failed'>
<IconMessage
formattedTitle={formattedTitle}
formattedSubtitle={formattedSubtitle}
icon={icon}
error={true}
formattedButtonText={formattedButtonText}
buttonHandler={() => {
//TODO: Open self hosted expansion modal
}}
formattedTertiaryButonText={tertiaryButtonText}
tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,140 @@
.SelfHostedExpansionRHSCard {
display: flex;
max-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-family: 'Open Sans';
font-size: 14px;
font-weight: 600;
text-align: center;
text-transform: capitalize;
}
.seatsInput {
width: 73px;
margin-left: auto;
font-family: 'Open Sans';
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-family: 'Metropolis';
font-size: 20px;
font-weight: 400;
text-transform: capitalize;
}
.usage {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56);
font-family: 'Open Sans';
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-family: 'Open Sans';
font-size: 14px;
}
.costPerUser > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-family: 'Open Sans';
font-size: 12px;
}
.totalCost {
width: 141px;
}
.totalCost > span:first-child {
color: var(--sys-denim-center-channel-text);
font-family: 'Open Sans';
font-size: 14px;
font-weight: 700;
}
.totalCost > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-family: 'Open Sans';
font-size: 12px;
}
.costAmount {
margin-right: 0;
margin-left: auto;
font-weight: 700;
}
}
&__AddSeatsWarning {
display: block;
width: 100%;
height: 35px;
margin-bottom: 15px;
color: var(--dnd-indicator);
font-family: 'Open Sans';
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-family: 'Open Sans';
font-size: 12px;
font-weight: 400;
}
}

Просмотреть файл

@@ -0,0 +1,268 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {OutlinedInput} from '@mui/material';
import moment from 'moment-timezone';
import React, {Fragment, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {DocLinks, RecurringIntervals} from 'utils/constants';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
import './expansion_card.scss';
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import ExternalLink from 'components/external_link';
const MONTHS_IN_YEAR = 12;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MAX_TRANSACTION_VALUE = 1_000_000 - 1;
interface Props {
canSubmit: boolean;
licensedSeats: number;
initialSeats: number;
submit: () => void;
updateSeats: (seats: number) => void;
}
export default function SelfHostedExpansionCard(props: Props) {
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.initialSeats);
const [overMaxSeats, setOverMaxSeats] = useState(false);
const licenseExpiry = parseInt(license.ExpiresAt, 10);
const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats);
const [products] = useGetSelfHostedProducts();
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
const getMonthsUntilExpiry = () => {
const now = new Date();
return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30);
};
const getMonthlyPrice = () => {
if (currentProduct === null) {
return 0;
}
if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) {
return currentProduct.price_per_seat;
}
const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR);
// Only display 2 decimal places if the cost per month is not evenly divisible over 12 months.
if (!Number.isInteger(costPerMonth)) {
// Keep the return value as a number.
return costPerMonth;
}
return costPerMonth;
};
const getCostPerUser = () => {
if (isNaN(additionalSeats)) {
return 0;
}
const monthlyPrice = getMonthlyPrice();
const monthsUntilExpiry = getMonthsUntilExpiry();
return monthlyPrice * monthsUntilExpiry;
};
const getTotal = () => {
if (isNaN(additionalSeats)) {
return 0;
}
const monthlyPrice = getMonthlyPrice();
const monthsUntilExpiry = getMonthsUntilExpiry();
return additionalSeats * monthlyPrice * 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 * Price Per Seat)) / price_per_seat
const getMaximumAdditionalSeats = () => {
if (currentProduct === null) {
return 0;
}
let recurringCost = 0;
// if monthly
if (currentProduct.recurring_interval === RecurringIntervals.MONTH) {
recurringCost = getMonthlyPrice();
} else { // if yearly
recurringCost = currentProduct.price_per_seat;
}
const currentPaymentPrice = recurringCost * props.licensedSeats;
const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice;
const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost);
return Math.max(0, remainingSeats);
};
const maxAdditionalSeats = getMaximumAdditionalSeats();
const handleNewSeatsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setOverMaxSeats(false);
const requestedSeats = parseInt(e.target.value, 10);
const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats;
setOverMaxSeats(overMaxAdditionalSeats);
const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats;
setAdditionalSeats(finalSeatCount);
props.updateSeats(finalSeatCount);
};
return (
<div className='SelfHostedExpansionRHSCard'>
<div className='SelfHostedExpansionRHSCard__RHSCardTitle'>
<FormattedMessage
id='self_hosted_expansion_rhs_license_summary_title'
defaultMessage='License Summary'
/>
</div>
<div className='SelfHostedExpansionRHSCard__Content'>
<div className='SelfHostedExpansionRHSCard__PlanDetails'>
<span className='planName'>{license.SkuShortName}</span>
<div className='usage'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_license_date'
defaultMessage='{startsAt} - {endsAt}'
values={{
startsAt,
endsAt,
}}
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_licensed_seats'
defaultMessage='{licensedSeats} LICENSES SEATS'
values={{
licensedSeats: props.licensedSeats,
}}
/>
</div>
</div>
<hr/>
<div className='SelfHostedExpansionRHSCard__seatInput'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_add_new_seats'
defaultMessage='Add new seats'
/>
<OutlinedInput
data-testid='seatsInput'
className='seatsInput'
size='small'
type='number'
value={additionalSeats}
onChange={handleNewSeatsInputChange}
error={invalidAdditionalSeats}
disabled={maxAdditionalSeats === 0}
/>
</div>
<div className='SelfHostedExpansionRHSCard__AddSeatsWarning'>
{invalidAdditionalSeats && !overMaxSeats &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_must_add_seats_warning'
defaultMessage='{warningIcon} You must add a seat to continue'
values={{
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
{overMaxSeats && maxAdditionalSeats > 0 &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_maximum_seats_warning'
defaultMessage='{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats'
values={{
maxAdditionalSeats,
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
{maxAdditionalSeats === 0 &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_additional_seats_limit_warning'
defaultMessage='{warningIcon} Transaction amount limit reached.{break}Please contact sales'
values={{
break: <br/>,
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
</div>
<div className='SelfHostedExpansionRHSCard__cost_breakdown'>
<div className='costPerUser'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_title'
defaultMessage='Cost per user'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_breakdown'
defaultMessage='{costPerUser} x {monthsUntilExpiry} months'
values={{
costPerUser: getMonthlyPrice().toFixed(2),
monthsUntilExpiry: getMonthsUntilExpiry(),
}}
/>
</div>
<div className='costAmount'>
<span>{'$' + getCostPerUser().toFixed(2)}</span>
</div>
<div className='totalCost'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_title'
defaultMessage='Total'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_prorated_warning'
defaultMessage='The total will be prorated'
/>
</div>
<span className='costAmount'>
<span>{'$' + getTotal().toFixed(2)}</span>
</span>
</div>
<button
className='btn btn-primary SelfHostedExpansionRHSCard__CompletePurchaseButton'
disabled={!props.canSubmit || maxAdditionalSeats === 0}
onClick={props.submit}
>
<FormattedMessage
id='self_hosted_expansion_rhs_complete_button'
defaultMessage='Complete purchase'
/>
</button>
<div className='SelfHostedExpansionRHSCard__ChargedTodayDisclaimer'>
<FormattedMessage
id='self_hosted_expansion_rhs_credit_card_charge_today_warning'
defaultMessage='Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>'
values={{
see_how_billing_works: (text: string) => (
<Fragment>
<br/>
<ExternalLink
href={DocLinks.SELF_HOSTED_BILLING}
>
{text}
</ExternalLink>
</Fragment>
),
}}
/>
</div>
</div>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,418 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {screen, fireEvent} 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} from 'utils/constants';
import {DeepPartial} from '@mattermost/types/utilities';
import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './';
interface MockCardInputProps {
onCardInputChange: (event: {complete: boolean}) => void;
forwardedRef: React.MutableRefObject<any>;
}
// number borrowed from stripe
const successCardNumber = '4242424242424242';
function MockCardInput(props: MockCardInputProps) {
props.forwardedRef.current = {
getCard: () => ({}),
};
return (
<input
placeholder='Card number'
type='text'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
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_purchase_modal/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,
});
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,
});
},
confirmSelfHostedSignup: () => Promise.resolve({
progress: mockCreatedLicense,
license: {Users: existingUsers * 2},
}),
getClientLicenseOld: () => Promise.resolve({
data: {Sku: 'Enterprise'},
}),
},
};
});
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;
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {
[ModalIdentifiers.SELF_HOSTED_EXPANSION]: {
open: true,
},
},
},
},
storage: {
storage: {},
},
entities: {
admin: {
analytics: {
TOTAL_USERS: existingUsers,
},
},
teams: {
currentTeamId: '',
},
preferences: {
myPreferences: {
theme: {},
},
},
general: {
config: {
EnableDeveloper: 'false',
},
license: {
Sku: productName,
Users: '50',
},
},
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;
}
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: '10',
};
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);
changeByTestId('seatsInput', form.seats);
expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled();
// not changing the license seats number,
// because it is expected to be pre-filled with the correct number of seats.
const completeButton = screen.getByText('Complete purchase');
if (form === defaultSuccessForm) {
expect(completeButton).toBeEnabled();
}
return completeButton;
}
describe('SelfHostedExpansionModal', () => {
it('renders the form', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, 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(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
fillForm(defaultSuccessForm);
});
it('disables expansion if too few seats or no seats entered', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
fillForm(defaultSuccessForm);
// 0 seats entered.
const tooFewSeats = 0;
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString()));
expect(screen.getByText('Complete purchase')).toBeDisabled();
expect(screen.getByText('You must add a seat to continue')).toBeVisible();
// No seats value entered.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined);
expect(screen.getByText('Complete purchase')).toBeDisabled();
expect(screen.getByText('You must add a seat to continue')).toBeVisible();
});
// it('happy path submit shows success screen', async () => {
// renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
// expect(screen.getByText('Complete purchase')).toBeDisabled();
// const upgradeButton = fillForm(defaultSuccessForm);
// upgradeButton.click();
// await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234});
// });
// it('sad path submit shows error screen', async () => {
// renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, 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 :: canSubmit', () => {
function makeHappyPathState(): FormState {
return {
address: 'string',
address2: 'string',
city: 'string',
state: 'string',
country: 'string',
postalCode: '12345',
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);
});
// TODO: Needed?
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);
});
});

Просмотреть файл

@@ -0,0 +1,503 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {StripeCardElementChangeEvent} from '@stripe/stripe-js';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import RootPortal from 'components/root_portal';
import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link';
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 {COUNTRIES} from 'utils/countries';
import StateSelector from 'components/payment_form/state_selector';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import DropdownInput from 'components/dropdown_input';
import StripeProvider from '../self_hosted_purchase_modal/stripe_provider';
import {closeModal} from 'actions/views/modals';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users';
import {pageVisited} from 'actions/telemetry_actions';
import {Client4} from 'mattermost-redux/client';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import {inferNames} from 'utils/hosted_customer';
import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import {isDevModeEnabled} from 'selectors/general';
import {getLicenseConfig} from 'mattermost-redux/actions/general';
import {confirmSelfHostedExpansion} from 'actions/hosted_customer';
import {DispatchFunc} from 'mattermost-redux/types/actions';
import {ValueOf} from '@mattermost/types/utilities';
import SelfHostedExpansionCard from './expansion_card';
import './self_hosted_expansion_modal.scss';
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants';
export interface FormState {
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
cardName: string;
organization: string;
cardFilled: boolean;
seats: number;
submitting: boolean;
succeeded: boolean;
progressBar: number;
error: string;
}
export function makeInitialState(seats: number): FormState {
return {
address: '',
address2: '',
city: '',
state: '',
country: '',
postalCode: '',
cardName: '',
organization: '',
cardFilled: false,
seats,
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
export function canSubmit(formState: FormState, progress: ValueOf<typeof SelfHostedSignupProgress>) {
if (formState.submitting) {
return false;
}
const validAddress = Boolean(
formState.organization &&
formState.address &&
formState.city &&
formState.state &&
formState.postalCode &&
formState.country,
);
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 &&
validSeats,
);
}
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return Boolean(
validCard &&
validAddress &&
validSeats,
);
default: {
return false;
}
}
}
export default function SelfHostedExpansionModal() {
const dispatch = useDispatch<DispatchFunc>();
const intl = useIntl();
const cardRef = useRef<CardInputType | null>(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 activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0;
const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats);
const [stripeLoadHint, setStripeLoadHint] = useState(Math.random());
const stripeRef = useLoadStripe(stripeLoadHint);
const initialState = makeInitialState(additionalSeats);
const [formState, setFormState] = useState<FormState>(initialState);
const [show] = useState(true);
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;
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,
},
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';
// eslint-disable-next-line no-console
console.error(message);
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,
},
));
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) {
// eslint-disable-next-line no-console
console.error('could not complete setup', 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 (
<StripeProvider
stripeRef={stripeRef}
>
<RootPortal>
<FullScreenModal
show={show}
ariaLabelledBy='self_hosted_expansion_modal_title'
onClose={() => {
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
resetToken();
}}
>
<div className='SelfHostedExpansionModal'>
<div className='form-view'>
<div className='lhs'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
width={267}
height={227}
/>
<div className='footer-text'>{'Questions?'}</div>
<ContactSalesLink/>
</div>
<div className='center'>
<div
className='form'
data-testid='shpm-form'
>
<span className='section-title'>
{intl.formatMessage({
id: 'payment_form.credit_card',
defaultMessage: 'Credit Card',
})}
</span>
<div className='form-row'>
<CardInput
forwardedRef={cardRef}
required={true}
onCardInputChange={(event: StripeCardElementChangeEvent) => {
setFormState({...formState, cardFilled: event.complete});
}}
theme={theme}
/>
</div>
<div className='form-row'>
<Input
name='organization'
type='text'
value={formState.organization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, organization: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'self_hosted_signup.organization',
defaultMessage: 'Organization Name',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='name'
type='text'
value={formState.cardName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, cardName: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.name_on_card',
defaultMessage: 'Name on Card',
})}
required={true}
/>
</div>
<span className='section-title'>
{intl.formatMessage({
id: 'payment_form.billing_address',
defaultMessage: 'Billing address',
})}
</span>
<DropdownInput
testId='selfHostedExpansionCountrySelector'
onChange={(option: {value: string}) => {
setFormState({...formState, country: option.value});
}}
value={
formState.country ? {value: formState.country, label: formState.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
<div className='form-row'>
<Input
name='address'
type='text'
value={formState.address}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, address: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={formState.address2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, address2: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={formState.city}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, city: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className='form-row-third-1'>
<StateSelector
testId='selfHostedExpansionStateSelector'
country={formState.country}
state={formState.state}
onChange={(state: string) => {
setFormState({...formState, state});
}}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={formState.postalCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, postalCode: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</div>
</div>
<div className='rhs'>
<SelfHostedExpansionCard
updateSeats={(seats: number) => {
setFormState({...formState, seats});
setAdditionalSeats(seats);
}}
canSubmit={canSubmitForm}
submit={submit}
licensedSeats={licensedSeats}
initialSeats={additionalSeats}
/>
</div>
</div>
{/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && (
<SuccessPage
onClose={controlModal.close}
planName={desiredPlanName}
/>
)}
{formState.submitting && (
<Submitting
desiredPlanName={desiredPlanName}
progressBar={formState.progressBar}
/>
)}
{formState.error && (
<ErrorPage
nextAction={errorAction}
canRetry={canRetry}
errorType={canRetry ? 'generic' : 'failed_export'}
/>
)} */}
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
</FullScreenModal>
</RootPortal>
</StripeProvider>
);
}

Просмотреть файл

@@ -0,0 +1,178 @@
.SelfHostedExpansionModal {
height: 100%;
.form-view {
display: flex;
overflow: hidden;
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;
.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 {
z-index: 99999;
margin-top: 0;
}
}
.DropdownInput {
position: relative;
z-index: 999999;
height: 36px;
margin-bottom: 24px;
.Input_fieldset {
height: 43px;
}
}
.form-row-third-2 {
width: 34%;
max-width: 144px;
}
.section-title {
margin-bottom: 24px;
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 {
height: 32px;
background: inherit;
}
.Input_wrapper {
margin: 0;
}
}
}
>.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] {
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;
}
}
}

Просмотреть файл

@@ -0,0 +1,20 @@
.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-family: "Open Sans";
font-size: 16px;
font-weight: 600;
}
.self_hosted_expansion_success {
margin-top: 163px;
}

Просмотреть файл

@@ -0,0 +1,77 @@
// 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 {NavLink} from 'react-router-dom';
import {useDispatch} from 'react-redux';
import IconMessage from 'components/purchase_modal/icon_message';
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
import {ConsolePages, ModalIdentifiers} from 'utils/constants';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import {closeModal} from 'actions/views/modals';
import './success_page.scss';
export default function SelfHostedExpansionSuccessPage() {
const dispatch = useDispatch();
const titleText = (
<FormattedMessage
id={'self_hosted_expansion.expand_success'}
defaultMessage={'You\'ve successfully updated your license seat count'}
/>
);
const formattedSubtitleText = (
<FormattedMessage
id={'self_hosted_expansion.license_applied'}
defaultMessage={'The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.'}
values={{
billing: (billingText: React.ReactNode) => (
<NavLink
to={ConsolePages.BILLING_HISTORY}
target='_blank'
rel='noreferrer'
>
{billingText}
</NavLink>
),
}}
/>
);
const formattedButtonText = (
<FormattedMessage
id={'self_hosted_expansion.close'}
defaultMessage={'Close'}
/>
);
const icon = (
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_success'>
<IconMessage
className={'selfHostedExpansionModal__success'}
formattedTitle={titleText}
formattedSubtitle={formattedSubtitleText}
testId='selfHostedExpansionSuccess'
icon={icon}
formattedButtonText={formattedButtonText}
buttonHandler={() => dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))}
/>
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
);
}

Просмотреть файл

@@ -27,6 +27,7 @@ import {isModalOpen} from 'selectors/views/modals';
import {isDevModeEnabled} from 'selectors/general';
import {COUNTRIES} from 'utils/countries';
import {inferNames} from 'utils/hosted_customer';
import {
ModalIdentifiers,
@@ -49,7 +50,6 @@ import useControlSelfHostedPurchaseModal from 'components/common/hooks/useContro
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
import {ValueOf} from '@mattermost/types/utilities';
import {UserProfile} from '@mattermost/types/users';
import {
SelfHostedSignupProgress,
SelfHostedSignupCustomerResponse,
@@ -270,17 +270,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();

Просмотреть файл

@@ -459,6 +459,7 @@ export const ModalIdentifiers = {
DELETE_WORKSPACE_RESULT: 'delete_workspace_result',
SCREENING_IN_PROGRESS: 'screening_in_progress',
CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly',
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
};
export const UserStatuses = {
@@ -738,6 +739,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',
@@ -1068,6 +1070,7 @@ export const CloudLinks = {
SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup',
DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments',
SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted',
SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html',
};
export const HostedCustomerLinks = {
@@ -1998,6 +2001,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 = {

Просмотреть файл

@@ -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<string, Product>, 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(' ')];
};

Просмотреть файл

@@ -32,6 +32,7 @@ import {
SelfHostedSignupCustomerResponse,
SelfHostedSignupSuccessResponse,
SelfHostedSignupBootstrapResponse,
SelfHostedExpansionRequest,
} from '@mattermost/types/hosted_customer';
import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
@@ -3892,6 +3893,13 @@ export default class Client4 {
);
};
confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => {
return this.doFetch<SelfHostedSignupSuccessResponse>(
`${this.getHostedCustomerRoute()}/confirm?expand=true`,
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})},
);
}
createPaymentMethod = async () => {
return this.doFetch(
`${this.getCloudRoute()}/payment`,

Просмотреть файл

@@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile {
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
getRequestState: RequestState;
}
export interface SelfHostedExpansionRequest {
seats: number;
}