From b40cd113fa4f0d8efd57ebb7e2bac017fb79e882 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 14:48:47 -0400 Subject: [PATCH 001/113] Migrate to mono-repo --- .../channels/src/actions/hosted_customer.tsx | 88 ++- .../enterprise_edition.scss | 25 +- .../enterprise_edition_left_panel.test.tsx | 86 ++- .../enterprise_edition_left_panel.tsx | 61 ++- .../common/hooks/useCanSelfHostedExpand.ts | 46 ++ .../useControlSelfHostedExpansionModal.ts | 93 ++++ .../useControlSelfHostedPurchaseModal.ts | 2 + .../purchase_in_progress_modal/index.test.tsx | 20 +- .../purchase_in_progress_modal/index.tsx | 4 +- .../self_hosted_expansion_modal/constants.tsx | 4 + .../error_page.scss | 3 + .../error_page.tsx | 70 +++ .../expansion_card.scss | 140 +++++ .../expansion_card.tsx | 268 ++++++++++ .../index.test.tsx | 418 +++++++++++++++ .../self_hosted_expansion_modal/index.tsx | 503 ++++++++++++++++++ .../self_hosted_expansion_modal.scss | 178 +++++++ .../success_page.scss | 20 + .../success_page.tsx | 77 +++ .../self_hosted_purchase_modal/index.tsx | 13 +- webapp/channels/src/utils/constants.tsx | 4 + webapp/channels/src/utils/hosted_customer.ts | 11 + webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/hosted_customer.ts | 4 + 24 files changed, 2111 insertions(+), 35 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts create mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx 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..69ab2d6e1b 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 { @@ -194,4 +209,4 @@ } } } -} +} \ No newline at end of file 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 ef6a3d387f..551af99212 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,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 = { @@ -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( + + + , + ); + + 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 c3f37bfd93..0d4a422a27 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,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 = ( } { @@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) } @@ -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} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +255,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 +293,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..acca8853e0 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -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]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..1cbd91372b 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -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/purchase_in_progress_modal/index.test.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx index 268d8dbb90..fc1b87159e 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx @@ -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 = 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_expansion_modal/constants.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx new file mode 100644 index 0000000000..83c13f3567 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx @@ -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'; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/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_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..76b9af34d4 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -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 = ( + + ); + + const formattedButtonText = ( + + ); + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ { + //TODO: Open self hosted expansion modal + }} + formattedTertiaryButonText={tertiaryButtonText} + tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..79efc3e325 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -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; + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..d79d6b66fc --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -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) => { + 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 ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && + , + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } + {maxAdditionalSeats === 0 && + , + warningIcon: , + }} + /> + } +
+
+
+ +
+ +
+
+ {'$' + getCostPerUser().toFixed(2)} +
+
+ +
+ +
+ + {'$' + getTotal().toFixed(2)} + +
+ +
+ ( + +
+ + {text} + +
+ ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..05f6386302 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -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; +} + +// 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_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 = { + 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(
, 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('disables expansion if too few seats or no seats entered', () => { + renderWithIntlAndStore(
, 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(
, 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(
, 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); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..914d464877 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -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) { + 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(); + 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 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(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 ( + + + { + 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} + /> +
+ + {intl.formatMessage({ + id: 'payment_form.billing_address', + defaultMessage: 'Billing address', + })} + + { + 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'} + /> +
+ ) => { + setFormState({...formState, address: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address', + defaultMessage: 'Address', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, address2: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address_2', + defaultMessage: 'Address 2', + })} + /> +
+
+ ) => { + setFormState({...formState, city: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.city', + defaultMessage: 'City', + })} + required={true} + /> +
+
+
+ { + setFormState({...formState, state}); + }} + /> +
+
+ ) => { + setFormState({...formState, postalCode: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.zipcode', + defaultMessage: 'Zip/Postal Code', + })} + required={true} + /> +
+
+
+
+
+ { + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + initialSeats={additionalSeats} + /> +
+
+ {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + + )} + {formState.submitting && ( + + )} + {formState.error && ( + + )} */} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..beb6c32e08 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -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; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..7b4bab61f8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss @@ -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; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..cda885fa0d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -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 = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + /> +
+ +
+
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 03bfbccddc..3f032a0dcc 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -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(); diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index d3426a4395..0d1ddb8d70 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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 = { 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 56e2f552f1..8aca0c6326 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( + `${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`, diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index d81ef15227..28f04fd561 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export interface SelfHostedExpansionRequest { + seats: number; +} From 9d8597b399c2dd22e48b723cadc63a6759b11d4b Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 15:11:17 -0400 Subject: [PATCH 002/113] add self hostded expansion mm-server parts. --- model/hosted_customer.go | 10 ++++++ server/channels/api4/hosted_customer.go | 42 +++++++++++++++++++------ server/channels/einterfaces/cloud.go | 1 + 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 4f1917bdaf..d2856eab45 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -32,6 +32,11 @@ type SelfHostedConfirmPaymentMethodRequest struct { Subscription CreateSubscriptionRequest `json:"subscription"` } +type SelfHostedExpansionConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` +} + // SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license. type SelfHostedSignupCustomerResponse struct { CustomerId string `json:"customer_id"` @@ -58,3 +63,8 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index 4792969c15..3662582891 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -65,9 +65,18 @@ func checkSelfHostedPurchaseEnabled(c *Context) bool { return enabled != nil && *enabled } +func checkSelfHostedExpansionEnabled(c *Context) bool { + config := c.App.Config() + if config == nil { + return false + } + enabled := config.ServiceSettings.SelfHostedExpansion + return enabled != nil && *enabled +} + func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { const where = "Api4.selfHostedBootstrap" - if !checkSelfHostedPurchaseEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } @@ -151,25 +160,40 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { return } + expand := r.URL.Query().Get("expand") == "true" + bodyBytes, err := io.ReadAll(r.Body) if err != nil { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } - var confirm model.SelfHostedConfirmPaymentMethodRequest - err = json.Unmarshal(bodyBytes, &confirm) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) if userErr != nil { c.Err = userErr return } - confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) + + var confirmResponse *model.SelfHostedSignupConfirmResponse + if expand { + var confirm model.SelfHostedExpansionConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + confirmResponse, err = c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) + } else { + var confirm model.SelfHostedConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + confirmResponse, err = c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) + } if err != nil { if confirmResponse != nil { c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) diff --git a/server/channels/einterfaces/cloud.go b/server/channels/einterfaces/cloud.go index b5d6a75b68..8d92474cc8 100644 --- a/server/channels/einterfaces/cloud.go +++ b/server/channels/einterfaces/cloud.go @@ -37,6 +37,7 @@ type CloudInterface interface { BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) + ConfirmSelfHostedExpansion(req model.SelfHostedExpansionConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) ConfirmSelfHostedSignupLicenseApplication() error GetSelfHostedInvoices() ([]*model.Invoice, error) GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) From 9cea0fd266f15178749ce1bd6e6a76f490bfef28 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:30:07 -0400 Subject: [PATCH 003/113] fix cost per user movement when total is large. --- .../components/self_hosted_expansion_modal/expansion_card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 79efc3e325..0ac7a31fd4 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -106,6 +106,7 @@ } .costAmount { + width: 100%; margin-right: 0; margin-left: auto; font-weight: 700; From ff01fabc32821268dc3810846ec13d7e8e09df7f Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:30:26 -0400 Subject: [PATCH 004/113] add mocks. --- plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- .../einterfaces/mocks/CloudInterface.go | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index a084188c62..c54c6ac7bb 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 6093048d54..87e79ca7e6 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type hooksTimerLayer struct { diff --git a/server/channels/einterfaces/mocks/CloudInterface.go b/server/channels/einterfaces/mocks/CloudInterface.go index db7c86acc2..b287dc0680 100644 --- a/server/channels/einterfaces/mocks/CloudInterface.go +++ b/server/channels/einterfaces/mocks/CloudInterface.go @@ -88,6 +88,29 @@ func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest * return r0 } +// ConfirmSelfHostedExpansion provides a mock function with given fields: req, requesterEmail +func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedExpansionConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { + ret := _m.Called(req, requesterEmail) + + var r0 *model.SelfHostedSignupConfirmResponse + if rf, ok := ret.Get(0).(func(model.SelfHostedExpansionConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { + r0 = rf(req, requesterEmail) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.SelfHostedExpansionConfirmPaymentMethodRequest, string) error); ok { + r1 = rf(req, requesterEmail) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ConfirmSelfHostedSignup provides a mock function with given fields: req, requesterEmail func (_m *CloudInterface) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { ret := _m.Called(req, requesterEmail) From 336176f8cb59621b6d1d784080fc85e7e5451277 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 11:12:08 -0400 Subject: [PATCH 005/113] remove get customer by license id functionality in favor of a check in CWS. --- model/hosted_customer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index d2856eab45..8d83d2792c 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -68,3 +68,7 @@ type SelfHostedExpansionRequest struct { Seats int `json:"seats"` LicenseId string `json:"license_id"` } + +type GetSelfHostedCustomerRequest struct { + LicenseID string `json:"license_id"` +} From a8a71d5deee66b7ca7087b63a7c0c75708471e94 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 10:44:27 -0400 Subject: [PATCH 006/113] Remove unused request struct. --- model/hosted_customer.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index ea293b0ef2..2337a48569 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -69,7 +69,3 @@ type SelfHostedExpansionRequest struct { Seats int `json:"seats"` LicenseId string `json:"license_id"` } - -type GetSelfHostedCustomerRequest struct { - LicenseID string `json:"license_id"` -} From 32ccd93ed8c8fb67c79c2ea20b0222c4396c139e Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:37:45 -0400 Subject: [PATCH 007/113] add license_id param. --- .../src/components/self_hosted_expansion_modal/index.tsx | 1 + webapp/platform/types/src/hosted_customer.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 914d464877..06eda5dafb 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -228,6 +228,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, + license_id: license.ID, }, )); diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index 28f04fd561..ddbfc3d9de 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -77,4 +77,5 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { export interface SelfHostedExpansionRequest { seats: number; + license_id: string; } From afb929b041713be7d8f15a2dc40a18fa1d28012d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 10:01:55 -0400 Subject: [PATCH 008/113] update expansion request. --- webapp/platform/types/src/hosted_customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index ddbfc3d9de..69e16f32a5 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -75,7 +75,7 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } -export interface SelfHostedExpansionRequest { +export type SelfHostedExpansionRequest = { seats: number; license_id: string; } From acb4c57ee10e34e28de54dbf218d37b8ac3d2c7c Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 11:49:23 -0400 Subject: [PATCH 009/113] Add shiping address and add back terms. --- .../self_hosted_expansion_modal/index.tsx | 262 ++++++++++-------- .../self_hosted_expansion_modal.scss | 5 +- 2 files changed, 149 insertions(+), 118 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 06eda5dafb..1a2320db7c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -47,18 +47,34 @@ import SelfHostedExpansionCard from './expansion_card'; import './self_hosted_expansion_modal.scss'; import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; +import Address from 'components/self_hosted_purchase_modal/address'; +import ChooseDifferentShipping from 'components/choose_different_shipping'; +import Terms from 'components/self_hosted_purchase_modal/terms'; export interface FormState { + cardName: string; + cardFilled: boolean; + address: string; address2: string; city: string; state: string; country: string; postalCode: string; - cardName: string; organization: string; - cardFilled: boolean; + seats: number; + + shippingSame: boolean; + shippingAddress: string; + shippingAddress2: string; + shippingCity: string; + shippingState: string; + shippingCountry: string; + shippingPostalCode: string; + + agreedTerms: boolean; + submitting: boolean; succeeded: boolean; progressBar: number; @@ -67,16 +83,24 @@ export interface FormState { export function makeInitialState(seats: number): FormState { return { + cardName: '', + cardFilled: false, address: '', address2: '', city: '', state: '', country: '', postalCode: '', - cardName: '', organization: '', - cardFilled: false, + shippingSame: true, + shippingAddress: '', + shippingAddress2: '', + shippingCity: '', + shippingState: '', + shippingCountry: '', + shippingPostalCode: '', seats, + agreedTerms: false, submitting: false, succeeded: false, progressBar: 0, @@ -97,6 +121,18 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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 && - validSeats, - ); - default: { - return false; - } + validShippingAddress && + validSeats && + agreedToTerms + ); + default: { + return false; + } } } @@ -173,6 +210,14 @@ export default function SelfHostedExpansionModal() { 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 { @@ -361,104 +406,87 @@ export default function SelfHostedExpansionModal() { />
- {intl.formatMessage({ - id: 'payment_form.billing_address', - defaultMessage: 'Billing address', - })} + - { +
{ 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'} + 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, address: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address', - defaultMessage: 'Address', - })} - required={true} - /> -
-
- ) => { - setFormState({...formState, address2: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address_2', - defaultMessage: 'Address 2', - })} - /> -
-
- ) => { - setFormState({...formState, city: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.city', - defaultMessage: 'City', - })} - required={true} - /> -
-
-
- { - setFormState({...formState, state}); + { + 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, postalCode: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.zipcode', - defaultMessage: 'Zip/Postal Code', - })} - required={true} - /> -
-
+ + )} + { + setFormState({...formState, agreedTerms: data}); + }} + />
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index beb6c32e08..888532b85e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,7 @@ .form-view { display: flex; - overflow: hidden; + overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -144,6 +144,9 @@ } input[type=checkbox] { + width: 17px; + height: 17px; + flex-shrink: 0; margin-right: 12px; } From 759943d24ed9579ccf1e59771f85ff577b2328de Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:21:22 -0400 Subject: [PATCH 010/113] add layers, add missing model. --- model/hosted_customer.go | 10 ++++++++++ plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 4f1917bdaf..d42102face 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -58,3 +58,13 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} + +type SelfHostedExpansionConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` +} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index a084188c62..c54c6ac7bb 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 6093048d54..87e79ca7e6 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type hooksTimerLayer struct { From e4cf521add6de43b0f85574576f78111f69c0c02 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:53:27 -0400 Subject: [PATCH 011/113] lint. --- .../enterprise_edition.scss | 2 +- .../self_hosted_expansion_modal/index.tsx | 47 +++++++++---------- .../self_hosted_expansion_modal.scss | 2 +- webapp/platform/client/src/client4.ts | 2 +- 4 files changed, 25 insertions(+), 28 deletions(-) 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 69ab2d6e1b..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 @@ -209,4 +209,4 @@ } } } -} \ No newline at end of file +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 1a2320db7c..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -19,10 +19,7 @@ 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'; @@ -124,11 +121,11 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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; - } + agreedToTerms, + ); + default: { + return false; + } } } @@ -273,7 +270,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, - license_id: license.ID, + license_id: license.Id, }, )); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 888532b85e..7166c369e7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,6 @@ .form-view { display: flex; - overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -16,6 +15,7 @@ font-family: "Open Sans"; font-size: 16px; font-weight: 600; + overflow-x: hidden; .title { font-size: 22px; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8aca0c6326..a71207f42f 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3896,7 +3896,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( `${this.getHostedCustomerRoute()}/confirm?expand=true`, - {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From 9f01e983430b33d83567476133dff2c493c8cee8 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 10:47:47 -0400 Subject: [PATCH 012/113] i18n. --- webapp/channels/src/i18n/en.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index c4f6025f80..d9ddc1a442 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1314,6 +1314,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?", @@ -4707,6 +4708,25 @@ "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_additional_seats_limit_warning": "{warningIcon} Transaction amount limit reached.{break}Please contact sales", + "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_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_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", From d4631a4add1a69dc37c6559a3b2cbba1c890e68f Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:30:15 -0400 Subject: [PATCH 013/113] fix links, types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- .../components/self_hosted_expansion_modal/error_page.tsx | 5 ++--- .../components/self_hosted_expansion_modal/index.test.tsx | 8 ++++++++ .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- .../src/components/self_hosted_purchase_modal/index.tsx | 1 + webapp/channels/src/utils/constants.tsx | 4 ++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index acca8853e0..ce77aff2a8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: SelfHostedExpansionModal, }); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 76b9af34d4..c811fc4c5c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -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'; @@ -14,7 +13,7 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; export default function SelfHostedExpansionErrorPage() { - const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( { 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, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..00672eddf2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); resetToken(); }} > diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 3f032a0dcc..b2b9a356ca 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -71,6 +71,7 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; +import {inferNames} from 'utils/hosted_customer'; export interface State { address: string; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 0d1ddb8d70..34343b7aa9 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -459,7 +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', + EXPANSION_IN_PROGRESS: 'expansion_in_progress', }; export const UserStatuses = { @@ -1070,7 +1070,6 @@ 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 = { @@ -1090,6 +1089,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', }; export const LicenseLinks = { From 378bdae7fe9d8da138fc84132892965953769ca2 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:52:21 -0400 Subject: [PATCH 014/113] lint. --- .../src/components/self_hosted_expansion_modal/error_page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index c811fc4c5c..80e852d584 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -6,7 +6,6 @@ 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'; From 5babdf3de747deb4dc6e285b397f0653b8074ddc Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 13:46:40 -0400 Subject: [PATCH 015/113] fix types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ++-- .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- webapp/channels/src/utils/constants.tsx | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index ce77aff2a8..bda0099ca8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, dialogType: SelfHostedExpansionModal, }); @@ -42,7 +42,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) // 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, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 00672eddf2..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); resetToken(); }} > diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 34343b7aa9..416f9f0b54 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -460,6 +460,7 @@ export const ModalIdentifiers = { 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', }; export const UserStatuses = { From 6afe8ce9b3fc4973320e1e354f78ef9edac2d695 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 14:48:47 -0400 Subject: [PATCH 016/113] Migrate to mono-repo --- .../channels/src/actions/hosted_customer.tsx | 88 ++- .../enterprise_edition.scss | 25 +- .../enterprise_edition_left_panel.test.tsx | 86 ++- .../enterprise_edition_left_panel.tsx | 61 ++- .../common/hooks/useCanSelfHostedExpand.ts | 46 ++ .../useControlSelfHostedExpansionModal.ts | 93 ++++ .../useControlSelfHostedPurchaseModal.ts | 2 + .../purchase_in_progress_modal/index.test.tsx | 20 +- .../purchase_in_progress_modal/index.tsx | 4 +- .../self_hosted_expansion_modal/constants.tsx | 4 + .../error_page.scss | 3 + .../error_page.tsx | 70 +++ .../expansion_card.scss | 140 +++++ .../expansion_card.tsx | 268 ++++++++++ .../index.test.tsx | 418 +++++++++++++++ .../self_hosted_expansion_modal/index.tsx | 503 ++++++++++++++++++ .../self_hosted_expansion_modal.scss | 178 +++++++ .../success_page.scss | 20 + .../success_page.tsx | 77 +++ .../self_hosted_purchase_modal/index.tsx | 15 +- webapp/channels/src/utils/constants.tsx | 4 + webapp/channels/src/utils/hosted_customer.ts | 11 + webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/hosted_customer.ts | 4 + 24 files changed, 2113 insertions(+), 35 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts create mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx 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..69ab2d6e1b 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 { @@ -194,4 +209,4 @@ } } } -} +} \ No newline at end of file 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 ef6a3d387f..551af99212 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,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 = { @@ -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( + + + , + ); + + 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 c3f37bfd93..0d4a422a27 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,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 = ( }
{ @@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) } @@ -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} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +255,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 +293,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..acca8853e0 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -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]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..1cbd91372b 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -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/purchase_in_progress_modal/index.test.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx index 268d8dbb90..fc1b87159e 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx @@ -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 = 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_expansion_modal/constants.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx new file mode 100644 index 0000000000..83c13f3567 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx @@ -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'; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/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_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..76b9af34d4 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -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 = ( + + ); + + const formattedButtonText = ( + + ); + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ { + //TODO: Open self hosted expansion modal + }} + formattedTertiaryButonText={tertiaryButtonText} + tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..79efc3e325 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -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; + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..d79d6b66fc --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -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) => { + 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 ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && + , + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } + {maxAdditionalSeats === 0 && + , + warningIcon: , + }} + /> + } +
+
+
+ +
+ +
+
+ {'$' + getCostPerUser().toFixed(2)} +
+
+ +
+ +
+ + {'$' + getTotal().toFixed(2)} + +
+ +
+ ( + +
+ + {text} + +
+ ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..05f6386302 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -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; +} + +// 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_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 = { + 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(
, 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('disables expansion if too few seats or no seats entered', () => { + renderWithIntlAndStore(
, 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(
, 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(
, 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); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..914d464877 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -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) { + 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(); + 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 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(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 ( + + + { + 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} + /> +
+ + {intl.formatMessage({ + id: 'payment_form.billing_address', + defaultMessage: 'Billing address', + })} + + { + 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'} + /> +
+ ) => { + setFormState({...formState, address: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address', + defaultMessage: 'Address', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, address2: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address_2', + defaultMessage: 'Address 2', + })} + /> +
+
+ ) => { + setFormState({...formState, city: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.city', + defaultMessage: 'City', + })} + required={true} + /> +
+
+
+ { + setFormState({...formState, state}); + }} + /> +
+
+ ) => { + setFormState({...formState, postalCode: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.zipcode', + defaultMessage: 'Zip/Postal Code', + })} + required={true} + /> +
+
+
+
+
+ { + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + initialSeats={additionalSeats} + /> +
+
+ {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + + )} + {formState.submitting && ( + + )} + {formState.error && ( + + )} */} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..beb6c32e08 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -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; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..7b4bab61f8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss @@ -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; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..cda885fa0d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -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 = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + /> +
+ +
+
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index af43bfd229..6dd1332b49 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -26,6 +26,9 @@ import {GlobalState} from 'types/store'; import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; +import {COUNTRIES} from 'utils/countries'; +import {inferNames} from 'utils/hosted_customer'; + import { ModalIdentifiers, StatTypes, @@ -46,7 +49,6 @@ 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, @@ -309,17 +311,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/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index ad18170d15..9febc6e1c6 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -461,6 +461,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 = { @@ -740,6 +741,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', @@ -1069,6 +1071,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 = { @@ -1999,6 +2002,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 56e2f552f1..8aca0c6326 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( + `${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`, diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index fcd5b4e70b..bb6b7f856a 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -75,3 +75,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export interface SelfHostedExpansionRequest { + seats: number; +} From cd5b836015dfa5737e3ab09fc099db9600a0ee2c Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:30:07 -0400 Subject: [PATCH 017/113] fix cost per user movement when total is large. --- .../components/self_hosted_expansion_modal/expansion_card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 79efc3e325..0ac7a31fd4 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -106,6 +106,7 @@ } .costAmount { + width: 100%; margin-right: 0; margin-left: auto; font-weight: 700; From d5a891702c51e198e7e452dfe0f0d174d4022655 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:37:45 -0400 Subject: [PATCH 018/113] add license_id param. --- .../src/components/self_hosted_expansion_modal/index.tsx | 1 + webapp/platform/types/src/hosted_customer.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 914d464877..06eda5dafb 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -228,6 +228,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, + license_id: license.ID, }, )); diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index bb6b7f856a..73878205af 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -78,4 +78,5 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { export interface SelfHostedExpansionRequest { seats: number; + license_id: string; } From 1941349ba9dcacbeea047d12d2b95c791b9d09b5 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 10:01:55 -0400 Subject: [PATCH 019/113] update expansion request. --- webapp/platform/types/src/hosted_customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index 73878205af..8b3a7099f7 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -76,7 +76,7 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } -export interface SelfHostedExpansionRequest { +export type SelfHostedExpansionRequest = { seats: number; license_id: string; } From 923ae3941e5a9426d4928042675d276a97adad24 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 11:49:23 -0400 Subject: [PATCH 020/113] Add shiping address and add back terms. --- .../self_hosted_expansion_modal/index.tsx | 262 ++++++++++-------- .../self_hosted_expansion_modal.scss | 5 +- 2 files changed, 149 insertions(+), 118 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 06eda5dafb..1a2320db7c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -47,18 +47,34 @@ import SelfHostedExpansionCard from './expansion_card'; import './self_hosted_expansion_modal.scss'; import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; +import Address from 'components/self_hosted_purchase_modal/address'; +import ChooseDifferentShipping from 'components/choose_different_shipping'; +import Terms from 'components/self_hosted_purchase_modal/terms'; export interface FormState { + cardName: string; + cardFilled: boolean; + address: string; address2: string; city: string; state: string; country: string; postalCode: string; - cardName: string; organization: string; - cardFilled: boolean; + seats: number; + + shippingSame: boolean; + shippingAddress: string; + shippingAddress2: string; + shippingCity: string; + shippingState: string; + shippingCountry: string; + shippingPostalCode: string; + + agreedTerms: boolean; + submitting: boolean; succeeded: boolean; progressBar: number; @@ -67,16 +83,24 @@ export interface FormState { export function makeInitialState(seats: number): FormState { return { + cardName: '', + cardFilled: false, address: '', address2: '', city: '', state: '', country: '', postalCode: '', - cardName: '', organization: '', - cardFilled: false, + shippingSame: true, + shippingAddress: '', + shippingAddress2: '', + shippingCity: '', + shippingState: '', + shippingCountry: '', + shippingPostalCode: '', seats, + agreedTerms: false, submitting: false, succeeded: false, progressBar: 0, @@ -97,6 +121,18 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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 && - validSeats, - ); - default: { - return false; - } + validShippingAddress && + validSeats && + agreedToTerms + ); + default: { + return false; + } } } @@ -173,6 +210,14 @@ export default function SelfHostedExpansionModal() { 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 { @@ -361,104 +406,87 @@ export default function SelfHostedExpansionModal() { />
- {intl.formatMessage({ - id: 'payment_form.billing_address', - defaultMessage: 'Billing address', - })} + - { +
{ 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'} + 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, address: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address', - defaultMessage: 'Address', - })} - required={true} - /> -
-
- ) => { - setFormState({...formState, address2: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address_2', - defaultMessage: 'Address 2', - })} - /> -
-
- ) => { - setFormState({...formState, city: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.city', - defaultMessage: 'City', - })} - required={true} - /> -
-
-
- { - setFormState({...formState, state}); + { + 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, postalCode: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.zipcode', - defaultMessage: 'Zip/Postal Code', - })} - required={true} - /> -
-
+ + )} + { + setFormState({...formState, agreedTerms: data}); + }} + />
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index beb6c32e08..888532b85e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,7 @@ .form-view { display: flex; - overflow: hidden; + overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -144,6 +144,9 @@ } input[type=checkbox] { + width: 17px; + height: 17px; + flex-shrink: 0; margin-right: 12px; } From 7d152b2cd175890967b683477afccf9d20d0bb18 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:21:22 -0400 Subject: [PATCH 021/113] add layers, add missing model. --- model/hosted_customer.go | 10 ++++++++++ plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 543ea12b74..572537c428 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -59,3 +59,13 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} + +type SelfHostedExpansionConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` +} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index a084188c62..c54c6ac7bb 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 6093048d54..87e79ca7e6 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type hooksTimerLayer struct { From c4f6f21ca0821e1f478a7394ce449aa264caec4d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:53:27 -0400 Subject: [PATCH 022/113] lint. --- .../enterprise_edition.scss | 2 +- .../self_hosted_expansion_modal/index.tsx | 47 +++++++++---------- .../self_hosted_expansion_modal.scss | 2 +- webapp/platform/client/src/client4.ts | 2 +- 4 files changed, 25 insertions(+), 28 deletions(-) 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 69ab2d6e1b..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 @@ -209,4 +209,4 @@ } } } -} \ No newline at end of file +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 1a2320db7c..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -19,10 +19,7 @@ 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'; @@ -124,11 +121,11 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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; - } + agreedToTerms, + ); + default: { + return false; + } } } @@ -273,7 +270,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, - license_id: license.ID, + license_id: license.Id, }, )); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 888532b85e..7166c369e7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,6 @@ .form-view { display: flex; - overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -16,6 +15,7 @@ font-family: "Open Sans"; font-size: 16px; font-weight: 600; + overflow-x: hidden; .title { font-size: 22px; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8aca0c6326..a71207f42f 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3896,7 +3896,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( `${this.getHostedCustomerRoute()}/confirm?expand=true`, - {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From 8b76d05f986e466fd0490ce98f46a6682a888de7 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 10:47:47 -0400 Subject: [PATCH 023/113] i18n. --- webapp/channels/src/i18n/en.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b7458e403a..2510bf6364 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1314,6 +1314,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?", @@ -4700,6 +4701,25 @@ "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_additional_seats_limit_warning": "{warningIcon} Transaction amount limit reached.{break}Please contact sales", + "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_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_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", From 2dcc12d242ed70f0d7203a991b09eb9ed0c512a2 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:30:15 -0400 Subject: [PATCH 024/113] fix links, types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- .../components/self_hosted_expansion_modal/error_page.tsx | 5 ++--- .../components/self_hosted_expansion_modal/index.test.tsx | 8 ++++++++ .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- .../src/components/self_hosted_purchase_modal/index.tsx | 1 + webapp/channels/src/utils/constants.tsx | 4 ++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index acca8853e0..ce77aff2a8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: SelfHostedExpansionModal, }); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 76b9af34d4..c811fc4c5c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -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'; @@ -14,7 +13,7 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; export default function SelfHostedExpansionErrorPage() { - const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( { 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, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..00672eddf2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); resetToken(); }} > diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 6dd1332b49..aa8f41fdd4 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -71,6 +71,7 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; +import {inferNames} from 'utils/hosted_customer'; export interface State { diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9febc6e1c6..e20039599e 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -461,7 +461,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', + EXPANSION_IN_PROGRESS: 'expansion_in_progress', }; export const UserStatuses = { @@ -1071,7 +1071,6 @@ 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 = { @@ -1091,6 +1090,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', }; export const LicenseLinks = { From 4856f846dab0c1825fd90a9b3bdf727515593dcb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:52:21 -0400 Subject: [PATCH 025/113] lint. --- .../src/components/self_hosted_expansion_modal/error_page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index c811fc4c5c..80e852d584 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -6,7 +6,6 @@ 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'; From 43b25fa4881d4e5a931b1c41978da20f0b94da40 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 13:46:40 -0400 Subject: [PATCH 026/113] fix types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ++-- .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- webapp/channels/src/utils/constants.tsx | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index ce77aff2a8..bda0099ca8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, dialogType: SelfHostedExpansionModal, }); @@ -42,7 +42,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) // 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, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 00672eddf2..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); resetToken(); }} > diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index e20039599e..dd620c2840 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -462,6 +462,7 @@ export const ModalIdentifiers = { 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', }; export const UserStatuses = { From 406df06ef68779c8b8da11e73dbcd3d17c0e2dbd Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:37:33 -0400 Subject: [PATCH 027/113] Revert "add layers, add missing model." This reverts commit 7d152b2cd175890967b683477afccf9d20d0bb18. --- model/hosted_customer.go | 10 ---------- plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 572537c428..543ea12b74 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -59,13 +59,3 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } - -type SelfHostedExpansionRequest struct { - Seats int `json:"seats"` - LicenseId string `json:"license_id"` -} - -type SelfHostedExpansionConfirmPaymentMethodRequest struct { - StripeSetupIntentID string `json:"stripe_setup_intent_id"` - ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` -} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index c54c6ac7bb..a084188c62 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 87e79ca7e6..6093048d54 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" ) type hooksTimerLayer struct { From 93214efc54f980fe2a7014c4650c3ae5c1124037 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:41:28 -0400 Subject: [PATCH 028/113] add further check for ability to expand. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index bda0099ca8..42ef497e86 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -16,6 +16,7 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_expansio import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal'; import {useControlModal, ControlModal} from './useControlModal'; +import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ onClick?: () => void; @@ -25,6 +26,7 @@ interface HookOptions{ 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, @@ -34,6 +36,10 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) 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. From dd7e84aca2de629265ba39de7b07a51e20f8825f Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:46:27 -0400 Subject: [PATCH 029/113] Add missing checks for self hosted expansion config enabled. --- server/channels/api4/hosted_customer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index e5ab945611..b82fb8719f 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -114,7 +114,7 @@ func selfHostedCustomer(c *Context, w http.ResponseWriter, r *http.Request) { if c.Err != nil { return } - if !checkSelfHostedPurchaseEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } @@ -157,7 +157,7 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { if c.Err != nil { return } - if !checkSelfHostedPurchaseEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } From 5df7e62f8ab7a65ceefbdace47939a42936d9a14 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:50:30 -0400 Subject: [PATCH 030/113] lint. --- .../src/components/self_hosted_purchase_modal/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index aa8f41fdd4..d4b55b80a4 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -26,7 +26,6 @@ import {GlobalState} from 'types/store'; import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; -import {COUNTRIES} from 'utils/countries'; import {inferNames} from 'utils/hosted_customer'; import { @@ -71,7 +70,6 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; -import {inferNames} from 'utils/hosted_customer'; export interface State { From 58fead7d9dc381e5254b2d1861ea693b32fff27d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 16:15:14 -0400 Subject: [PATCH 031/113] add more e2e tests. --- .../expansion_card.scss | 6 +- .../expansion_card.tsx | 11 ++-- .../index.test.tsx | 64 ++++++++++++++++--- .../self_hosted_expansion_modal/index.tsx | 2 + 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 0ac7a31fd4..f9d7ca4d36 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -88,18 +88,18 @@ font-size: 12px; } - .totalCost { + .totalCostWarning { width: 141px; } - .totalCost > span:first-child { + .totalCostWarning > span:first-child { color: var(--sys-denim-center-channel-text); font-family: 'Open Sans'; font-size: 14px; font-weight: 700; } - .totalCost > span:last-child { + .totalCostWarning > span:last-child { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); font-family: 'Open Sans'; font-size: 12px; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index d79d6b66fc..6eadd2de6e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -18,7 +18,6 @@ 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 { @@ -35,14 +34,14 @@ export default function SelfHostedExpansionCard(props: Props) { 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 licenseExpiry = new Date(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); + return (licenseExpiry.getMonth() - now.getMonth()) + 12 * (licenseExpiry.getFullYear() - now.getFullYear()); }; const getMonthlyPrice = () => { @@ -209,7 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
{'$' + getCostPerUser().toFixed(2)}
-
+
- + {'$' + getTotal().toFixed(2)}
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index c44aae70a3..0097108ae8 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import {screen, fireEvent} from '@testing-library/react'; +import {screen, fireEvent, waitFor} from '@testing-library/react'; import {GlobalState} from 'types/store'; @@ -11,7 +11,7 @@ import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/ import {renderWithIntlAndStore} from 'tests/react_testing_utils'; import {TestHelper as TH} from 'utils/test_helper'; -import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants'; +import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants'; import {DeepPartial} from '@mattermost/types/utilities'; @@ -76,6 +76,7 @@ const mockProfessionalProduct = TH.getProductMock({ name: 'Professional', sku: SelfHostedProducts.PROFESSIONAL, price_per_seat: 7.5, + recurring_interval: RecurringIntervals.MONTH }); jest.mock('mattermost-redux/client', () => { @@ -129,6 +130,11 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; +// Licensed expiry set as 3 months from the current date (rolls over to new years). +const licenseExpiry = new Date(); +const monthsUntilLicenseExpiry = 3; +licenseExpiry.setMonth(licenseExpiry.getMonth() + monthsUntilLicenseExpiry); + const initialState: DeepPartial = { views: { modals: { @@ -143,11 +149,6 @@ const initialState: DeepPartial = { storage: {}, }, entities: { - admin: { - analytics: { - TOTAL_USERS: existingUsers, - }, - }, teams: { currentTeamId: '', }, @@ -163,6 +164,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', + ExpiresAt: licenseExpiry.getTime().toString() }, }, cloud: { @@ -224,6 +226,7 @@ interface PurchaseForm { state: string; zip: string; seats: string; + agree: boolean; } const defaultSuccessForm: PurchaseForm = { @@ -236,6 +239,7 @@ const defaultSuccessForm: PurchaseForm = { state: 'MN', zip: '55423', seats: '10', + agree: true, }; function fillForm(form: PurchaseForm) { @@ -248,6 +252,9 @@ function fillForm(form: PurchaseForm) { selectDropdownValue('selfHostedExpansionStateSelector', form.state); changeByPlaceholder('Zip/Postal Code', form.zip); changeByTestId('seatsInput', form.seats); + if (form.agree) { + fireEvent.click(screen.getByText('I have read and agree', {exact: false})); + } expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); @@ -263,7 +270,7 @@ function fillForm(form: PurchaseForm) { return completeButton; } -describe('SelfHostedExpansionModal', () => { +describe('SelfHostedExpansionModal Open', () => { it('renders the form', () => { renderWithIntlAndStore(
, initialState); @@ -321,7 +328,45 @@ describe('SelfHostedExpansionModal', () => { // }); }); -describe('SelfHostedExpansionModal :: canSubmit', () => { +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 || '0', 10); + + const seatsField = screen.getByTestId('seatsInput').querySelector('input'); + expect(seatsField).toBeInTheDocument(); + expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); + }); + + 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.innerHTML).toContain('$' + expectedTotalCost) + }); +}); + +describe('SelfHostedExpansionModal Submit', () => { function makeHappyPathState(): FormState { return { address: 'string', @@ -366,7 +411,6 @@ describe('SelfHostedExpansionModal :: canSubmit', () => { expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); }); - // TODO: Needed? it('if created subscription, can submit', () => { const state = makeInitialState(1); state.submitting = false; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..845fafcfe2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -409,6 +409,7 @@ export default function SelfHostedExpansionModal() { />
{ @@ -450,6 +451,7 @@ export default function SelfHostedExpansionModal() { />
{ From 62b46a630d0f48f461f4f94512c4533177b0515a Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 29 Mar 2023 12:29:57 -0400 Subject: [PATCH 032/113] lint. --- .../self_hosted_expansion_modal/index.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 0097108ae8..7e19fb88cf 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -76,7 +76,7 @@ const mockProfessionalProduct = TH.getProductMock({ name: 'Professional', sku: SelfHostedProducts.PROFESSIONAL, price_per_seat: 7.5, - recurring_interval: RecurringIntervals.MONTH + recurring_interval: RecurringIntervals.MONTH, }); jest.mock('mattermost-redux/client', () => { @@ -164,7 +164,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', - ExpiresAt: licenseExpiry.getTime().toString() + ExpiresAt: licenseExpiry.getTime().toString(), }, }, cloud: { @@ -329,7 +329,7 @@ describe('SelfHostedExpansionModal Open', () => { }); describe('SelfHostedExpansionModal RHS Card', () => { - it("New seats input should be pre-populated with the difference from the active users and licensed seats", () => { + 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 || '0', 10); @@ -339,7 +339,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); }); - it("Cost per User should be represented as the current subscription price multiplied by the remaining months", () => { + 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; @@ -350,10 +350,10 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('costAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser) + 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", () => { + 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()); @@ -362,7 +362,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedTotalCost) + expect(costAmount.innerHTML).toContain('$' + expectedTotalCost); }); }); From 6fe45fe890973007f64e2abb9ccf58db8ff4ed3d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 31 Mar 2023 13:38:37 -0400 Subject: [PATCH 033/113] lint. --- .../components/self_hosted_expansion_modal/expansion_card.tsx | 3 ++- .../src/components/self_hosted_expansion_modal/index.test.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index 6eadd2de6e..47ecfa49aa 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -41,7 +41,7 @@ export default function SelfHostedExpansionCard(props: Props) { const getMonthsUntilExpiry = () => { const now = new Date(); - return (licenseExpiry.getMonth() - now.getMonth()) + 12 * (licenseExpiry.getFullYear() - now.getFullYear()); + return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear())); }; const getMonthlyPrice = () => { @@ -208,6 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
Date: Fri, 31 Mar 2023 15:53:25 -0400 Subject: [PATCH 034/113] Fix tests. --- .../self_hosted_expansion_modal/expansion_card.tsx | 2 +- .../self_hosted_expansion_modal/index.test.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index 47ecfa49aa..caa3afb2d9 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -208,7 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
void; @@ -131,9 +132,9 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; // Licensed expiry set as 3 months from the current date (rolls over to new years). -const licenseExpiry = new Date(); +let licenseExpiry = moment() const monthsUntilLicenseExpiry = 3; -licenseExpiry.setMonth(licenseExpiry.getMonth() + monthsUntilLicenseExpiry); +licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); const initialState: DeepPartial = { views: { @@ -164,7 +165,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', - ExpiresAt: licenseExpiry.getTime().toString(), + ExpiresAt: licenseExpiry.valueOf().toString(), }, }, cloud: { @@ -362,7 +363,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedTotalCost); + expect(costAmount).toHaveTextContent('$' + expectedTotalCost); }); }); From 379f19701e25af00726c21831260c1e70d378db7 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 31 Mar 2023 16:21:36 -0400 Subject: [PATCH 035/113] Add success, error, loading modals, lint. --- .../useControlSelfHostedExpansionModal.ts | 2 +- .../error_page.tsx | 17 ++++++++++++++-- .../expansion_card.tsx | 4 ++-- .../index.test.tsx | 2 +- .../self_hosted_expansion_modal/index.tsx | 20 +++++++++++-------- .../success_page.tsx | 11 ++++++++-- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index 42ef497e86..db44768fd0 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -20,7 +20,7 @@ import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ onClick?: () => void; - trackingLocation: string; + trackingLocation?: string; } export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 80e852d584..2b9748de26 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -11,7 +11,11 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; -export default function SelfHostedExpansionErrorPage() { +interface Props { + canRetry: boolean; +} + +export default function SelfHostedExpansionErrorPage(props: Props) { const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( @@ -21,13 +25,22 @@ export default function SelfHostedExpansionErrorPage() { /> ); - const formattedButtonText = ( + let formattedButtonText = ( ); + if (!props.canRetry) { + formattedButtonText = ( + + ); + } + const formattedSubtitle = ( ( - + <>
{text} -
+ ), }} /> diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 524cb26e1f..9ca3718f22 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -132,7 +132,7 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; // Licensed expiry set as 3 months from the current date (rolls over to new years). -let licenseExpiry = moment() +let licenseExpiry = moment(); const monthsUntilLicenseExpiry = 3; licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 845fafcfe2..efc62324a7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -12,6 +12,9 @@ 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 ErrorPage from 'components/self_hosted_expansion_modal/error_page'; +import SuccessPage from 'components/self_hosted_expansion_modal/success_page'; +import Submitting from 'components/self_hosted_purchase_modal/submitting'; import useLoadStripe from 'components/common/hooks/useLoadStripe'; import CardInput, {CardInputType} from 'components/payment_form/card_input'; @@ -47,6 +50,7 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; import Address from 'components/self_hosted_purchase_modal/address'; import ChooseDifferentShipping from 'components/choose_different_shipping'; import Terms from 'components/self_hosted_purchase_modal/terms'; +import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; export interface FormState { cardName: string; @@ -163,6 +167,7 @@ export function canSubmit(formState: FormState, progress: ValueOf(); const intl = useIntl(); const cardRef = useRef(null); @@ -173,6 +178,7 @@ export default function SelfHostedExpansionModal() { const license = useSelector(getLicense); const licensedSeats = parseInt(license.Users, 10); + const currentPlan = license.SkuName; const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); @@ -182,6 +188,7 @@ export default function SelfHostedExpansionModal() { const initialState = makeInitialState(additionalSeats); const [formState, setFormState] = useState(initialState); const [show] = useState(true); + const canRetry = formState.error !== '422'; const title = intl.formatMessage({ id: 'self_hosted_expansion.expansion_modal.title', @@ -501,25 +508,22 @@ export default function SelfHostedExpansionModal() { /> - {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( )} - {formState.submitting && ( + {formState.submitting && !formState.error && ( )} {formState.error && ( - )} */} + )}
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx index cda885fa0d..0e08a50b22 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -16,7 +16,11 @@ import {closeModal} from 'actions/views/modals'; import './success_page.scss'; -export default function SelfHostedExpansionSuccessPage() { +interface Props { + onClose: () => void; +} + +export default function SelfHostedExpansionSuccessPage(props: Props) { const dispatch = useDispatch(); const titleText = ( dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + buttonHandler={() => { + props.onClose(); + dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL)); + }} />
From c0d4b0dfdcc3e0958df375d001d3123666abaedb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 10:01:40 -0400 Subject: [PATCH 036/113] Fixup i18n, error/success/progress modals. --- .../error_page.tsx | 4 +- .../index.test.tsx | 43 ++++++++++--------- .../self_hosted_expansion_modal/index.tsx | 4 -- .../success_page.tsx | 2 +- webapp/channels/src/i18n/en.json | 1 + 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 2b9748de26..dc68cb4137 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -27,7 +27,7 @@ export default function SelfHostedExpansionErrorPage(props: Props) { let formattedButtonText = ( ); @@ -35,7 +35,7 @@ export default function SelfHostedExpansionErrorPage(props: Props) { if (!props.canRetry) { formattedButtonText = ( ); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 9ca3718f22..e793b267e3 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import {screen, fireEvent} from '@testing-library/react'; +import {screen, fireEvent, waitFor} from '@testing-library/react'; import {GlobalState} from 'types/store'; @@ -252,15 +252,15 @@ function fillForm(form: PurchaseForm) { changeByPlaceholder('City', form.city); selectDropdownValue('selfHostedExpansionStateSelector', form.state); changeByPlaceholder('Zip/Postal Code', form.zip); - changeByTestId('seatsInput', form.seats); if (form.agree) { fireEvent.click(screen.getByText('I have read and agree', {exact: false})); } + // not changing the license seats number, because it is expected to be pre-filled, + // with the correct number of seats (current active users - current licensed seats, or 1 if the difference is 0). + 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'); @@ -307,26 +307,27 @@ describe('SelfHostedExpansionModal Open', () => { expect(screen.getByText('You must add a seat to continue')).toBeVisible(); }); - // it('happy path submit shows success screen', async () => { - // renderWithIntlAndStore(
, initialState); - // expect(screen.getByText('Complete purchase')).toBeDisabled(); - // const upgradeButton = fillForm(defaultSuccessForm); + it('happy path submit shows success screen', async () => { + renderWithIntlAndStore(
, 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}); - // }); + expect(upgradeButton).toBeEnabled(); + upgradeButton.click(); + await waitFor(() => expect(screen.getByText('You\'ve successfully updated your license seat count')).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); + 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}); - // }); + 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', () => { diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index efc62324a7..4bb217bc32 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -252,8 +252,6 @@ export default function SelfHostedExpansionModal() { 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; } @@ -302,8 +300,6 @@ export default function SelfHostedExpansionModal() { } 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'}); } }; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx index 0e08a50b22..77916c9de7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -25,7 +25,7 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { const titleText = ( ); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 88c3105fb1..93298430ab 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4724,6 +4724,7 @@ "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", From 09e93fc5e4626afa11bfbf6d7751c937f17f75c6 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 10:15:27 -0400 Subject: [PATCH 037/113] lint. --- .../src/components/self_hosted_expansion_modal/index.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index e793b267e3..a0d1a62a6d 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -261,7 +261,6 @@ function fillForm(form: PurchaseForm) { expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); - const completeButton = screen.getByText('Complete purchase'); if (form === defaultSuccessForm) { From 0ce1b6c12c36a4bc55dc1ae37730d7108c039e37 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 17:02:54 -0400 Subject: [PATCH 038/113] move self hosted purchase modals into one folder for shared resources, update banners to redirect to the self hosted purchase modal (in the case of air gapped, link to CWS. In the future, this will be a modal). --- .../enterprise_edition_left_panel.tsx | 16 ++- .../overage_users_banner/index.tsx | 15 ++- .../useControlSelfHostedExpansionModal.ts | 4 +- .../useControlSelfHostedPurchaseModal.ts | 4 +- .../overage_users_banner_notice/index.tsx | 31 ++++- .../purchase_in_progress_modal/index.test.tsx | 2 +- .../self_hosted_expansion_modal/constants.tsx | 4 - .../address.tsx | 0 .../constants.ts | 1 + .../contact_sales_link.tsx | 0 .../error_page.scss | 0 .../error_page.tsx | 5 +- .../expansion_card.scss | 0 .../expansion_card.tsx | 0 .../index.test.tsx | 0 .../self_hosted_expansion_modal/index.tsx | 30 +++-- .../self_hosted_expansion_modal.scss | 4 + .../submitting.tsx | 122 ++++++++++++++++++ .../success_page.scss | 0 .../success_page.tsx | 0 .../self_hosted_purchase_modal/error.tsx | 0 .../self_hosted_purchase_modal/index.test.tsx | 2 +- .../self_hosted_purchase_modal/index.tsx | 10 +- .../self_hosted_card.tsx | 4 +- .../self_hosted_purchase_modal.scss | 0 .../self_hosted_purchase_modal/submitting.tsx | 0 .../success_page.scss | 0 .../success_page.tsx | 0 .../self_hosted_purchase_modal/terms.tsx | 0 .../self_hosted_purchase_modal/types.ts | 0 .../self_hosted_purchase_modal/useNoEscape.ts | 0 .../stripe_provider.tsx | 0 32 files changed, 214 insertions(+), 40 deletions(-) delete mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/address.tsx (100%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/constants.ts (71%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/contact_sales_link.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/error_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/error_page.tsx (95%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/expansion_card.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/expansion_card.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/index.test.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/index.tsx (94%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/self_hosted_expansion_modal.scss (98%) create mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/success_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/success_page.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/error.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/index.test.tsx (99%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/index.tsx (99%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/self_hosted_card.tsx (97%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/self_hosted_purchase_modal.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/submitting.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/success_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/success_page.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/terms.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/types.ts (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/useNoEscape.ts (100%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/stripe_provider.tsx (100%) 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 0d4a422a27..aea8e93cb5 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 @@ -23,6 +23,8 @@ 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'; +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; @@ -58,6 +60,19 @@ const EnterpriseEditionLeftPanel = ({ const canExpand = useCanSelfHostedExpand(); const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); const expandableLink = useSelector(getExpandSeatsLink); + const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; + + const query = useQuery(); + const actionQueryParam = query.get('action'); + + useEffect(() => { + console.log(actionQueryParam); + if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { + console.log("Open modal!"); + selfHostedExpansionModal.open(); + query.set('action', ''); + } + }, []) useEffect(() => { async function fetchUnSanitizedLicense() { @@ -73,7 +88,6 @@ 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 = (
} {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( {setFormState({...formState, submitting: false, error: ''})}} + tryAgain={() => { + setFormState({...formState, submitting: false, error: ''}); + }} /> )}
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 index 72cd5b6810..f734d5b82a 100644 --- 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 @@ -93,7 +93,7 @@ export default function Submitting(props: Props) { setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement)); } }, fakeProgressInterval); - + return () => clearInterval(interval); }, [barProgress]); From 9e41644c5b3e17556fd0b32f14e48488a6957dba Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 4 Apr 2023 16:55:25 -0400 Subject: [PATCH 040/113] lint. --- .../enterprise_edition_left_panel.tsx | 3 - .../self_hosted_expansion_modal/index.tsx | 138 +++++++++--------- 2 files changed, 69 insertions(+), 72 deletions(-) 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 ab7feb5107..432e38011e 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 @@ -24,7 +24,6 @@ import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpa import {getExpandSeatsLink} from 'selectors/cloud'; import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; import {useQuery} from 'utils/http_utils'; -import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; @@ -66,9 +65,7 @@ const EnterpriseEditionLeftPanel = ({ const actionQueryParam = query.get('action'); useEffect(() => { - console.log(actionQueryParam); if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { - console.log('Open modal!'); selfHostedExpansionModal.open(); query.set('action', ''); } 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 index 0661ad0637..83f6ff7320 100644 --- 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 @@ -356,98 +356,98 @@ export default function SelfHostedExpansionModal() {
{'Questions?'}
-
-
- - {intl.formatMessage({ +
+
+ + {intl.formatMessage({ id: 'payment_form.credit_card', defaultMessage: 'Credit Card', })} - -
- { + +
+ { setFormState({...formState, cardFilled: event.complete}); }} - theme={theme} - /> -
-
- ) => { + theme={theme} + /> +
+
+ ) => { setFormState({...formState, organization: e.target.value}); }} - placeholder={intl.formatMessage({ + placeholder={intl.formatMessage({ id: 'self_hosted_signup.organization', defaultMessage: 'Organization Name', })} - required={true} - /> -
-
- ) => { + required={true} + /> +
+
+ ) => { setFormState({...formState, cardName: e.target.value}); }} - placeholder={intl.formatMessage({ + placeholder={intl.formatMessage({ id: 'payment_form.name_on_card', defaultMessage: 'Name on Card', })} - required={true} - /> -
- - - -
{ + required={true} + /> +
+ + + +
{ setFormState({...formState, country: option.value}); }} - address={formState.address} - changeAddress={(e) => { + address={formState.address} + changeAddress={(e) => { setFormState({...formState, address: e.target.value}); }} - address2={formState.address2} - changeAddress2={(e) => { + address2={formState.address2} + changeAddress2={(e) => { setFormState({...formState, address2: e.target.value}); }} - city={formState.city} - changeCity={(e) => { + city={formState.city} + changeCity={(e) => { setFormState({...formState, city: e.target.value}); }} - state={formState.state} - changeState={(state: string) => { + state={formState.state} + changeState={(state: string) => { setFormState({...formState, state}); }} - postalCode={formState.postalCode} - changePostalCode={(e) => { + postalCode={formState.postalCode} + changePostalCode={(e) => { setFormState({...formState, postalCode: e.target.value}); }} - /> - { + /> + { setFormState({...formState, shippingSame: val}); }} - /> - {!formState.shippingSame && ( + /> + {!formState.shippingSame && ( <>
)} - { + { setFormState({...formState, agreedTerms: data}); }} - /> -
-
+ /> +
+
{ From d8544a1db7747da98a50cec46374129e6b4c5a07 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 10 Apr 2023 16:03:35 -0400 Subject: [PATCH 041/113] lint. --- .../expansion_card.tsx | 44 +++---------------- 1 file changed, 7 insertions(+), 37 deletions(-) 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 index 5528402613..3cf1243e17 100644 --- 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 @@ -9,7 +9,7 @@ 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 {DocLinks} from 'utils/constants'; import WarningIcon from 'components/widgets/icons/fa_warning_icon'; import './expansion_card.scss'; @@ -38,48 +38,27 @@ export default function SelfHostedExpansionCard(props: Props) { const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); 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 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; + return costPerMonth * monthsUntilExpiry; }; const getTotal = () => { if (isNaN(additionalSeats)) { return 0; } - const monthlyPrice = getMonthlyPrice(); const monthsUntilExpiry = getMonthsUntilExpiry(); - return additionalSeats * monthlyPrice * monthsUntilExpiry; + return additionalSeats * costPerMonth * monthsUntilExpiry; }; // Finds the maximum number of additional seats that is possible, taking into account @@ -90,18 +69,9 @@ export default function SelfHostedExpansionCard(props: Props) { 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 currentPaymentPrice = costPerMonth * props.licensedSeats; const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; - const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost); + const remainingSeats = Math.floor(remainingTransactionLimit / costPerMonth); return Math.max(0, remainingSeats); }; @@ -211,7 +181,7 @@ export default function SelfHostedExpansionCard(props: Props) { /* eslint-disable no-template-curly-in-string*/ defaultMessage='${costPerUser} x {monthsUntilExpiry} months' values={{ - costPerUser: getMonthlyPrice().toFixed(2), + costPerUser: costPerMonth.toFixed(2), monthsUntilExpiry: getMonthsUntilExpiry(), }} /> From 214bd6dd0709c71cbac94fcaebf34c8966c8f913 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 10 Apr 2023 16:17:36 -0400 Subject: [PATCH 042/113] lint. --- .../self_hosted_expansion_modal/index.tsx | 235 +++++++++--------- 1 file changed, 118 insertions(+), 117 deletions(-) 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 index 83f6ff7320..7eeedb5460 100644 --- 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 @@ -347,165 +347,166 @@ export default function SelfHostedExpansionModal() { }} >
- {
-

{title}

- -
{'Questions?'}
- -
-
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
- + className='form' + data-testid='shpm-form' + > + {intl.formatMessage({ - id: 'payment_form.credit_card', - defaultMessage: 'Credit Card', - })} + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} -
+
{ - setFormState({...formState, cardFilled: event.complete}); - }} - theme={theme} - /> + forwardedRef={cardRef} + required={true} + onCardInputChange={(event: StripeCardElementChangeEvent) => { + 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} - /> + name='organization' + type='text' + value={formState.organization} + onChange={(e: React.ChangeEvent) => { + 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} - /> + name='name' + type='text' + value={formState.cardName} + onChange={(e: React.ChangeEvent) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + />
- + + id='payment_form.billing_address' + defaultMessage='Billing address' + /> -
{ - setFormState({...formState, country: option.value}); - }} + setFormState({...formState, country: option.value}); + }} address={formState.address} changeAddress={(e) => { - setFormState({...formState, address: e.target.value}); - }} + setFormState({...formState, address: e.target.value}); + }} address2={formState.address2} changeAddress2={(e) => { - setFormState({...formState, address2: e.target.value}); - }} + setFormState({...formState, address2: e.target.value}); + }} city={formState.city} changeCity={(e) => { - setFormState({...formState, city: e.target.value}); - }} + setFormState({...formState, city: e.target.value}); + }} state={formState.state} changeState={(state: string) => { - setFormState({...formState, state}); - }} + setFormState({...formState, state}); + }} postalCode={formState.postalCode} changePostalCode={(e) => { - setFormState({...formState, postalCode: e.target.value}); - }} + setFormState({...formState, postalCode: e.target.value}); + }} /> - { - setFormState({...formState, shippingSame: val}); - }} + 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, 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, agreedTerms: data}); + }} /> -
+
- { - setFormState({...formState, seats}); - setAdditionalSeats(seats); - }} + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} canSubmit={canSubmitForm} submit={submit} licensedSeats={licensedSeats} initialSeats={additionalSeats} /> +
-
} {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( Date: Mon, 10 Apr 2023 17:16:10 -0400 Subject: [PATCH 043/113] fix some tests. --- .../self_hosted_expansion_modal/index.test.tsx | 3 ++- .../self_hosted_purchase_modal/index.test.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index a0d1a62a6d..882953af5f 100644 --- 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 @@ -51,7 +51,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; }; @@ -164,6 +164,7 @@ const initialState: DeepPartial = { }, license: { Sku: productName, + SkuName: productName, Users: '50', ExpiresAt: licenseExpiry.valueOf().toString(), }, diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx index bfce0c46d4..79f710f080 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx @@ -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; }; From 4ff63f60b0453e90df28dbd5c642d4ba0f887e26 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 09:38:43 -0400 Subject: [PATCH 044/113] fix tests for expansion modal, lint. --- .../useControlSelfHostedExpansionModal.ts | 1 - .../index.test.tsx | 31 ++++++++++--------- .../self_hosted_expansion_modal/index.tsx | 9 +++--- .../success_page.tsx | 22 ++++++------- webapp/channels/src/utils/constants.tsx | 2 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index dd581f23d5..df2d59eaf7 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -19,7 +19,6 @@ import {useControlModal, ControlModal} from './useControlModal'; import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ - onClick?: () => void; trackingLocation?: string; } 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 index 882953af5f..2d2931483e 100644 --- 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 @@ -98,13 +98,10 @@ jest.mock('mattermost-redux/client', () => { progress: mockCreatedIntent, }); }, - confirmSelfHostedSignup: () => Promise.resolve({ + confirmSelfHostedExpansion: () => Promise.resolve({ progress: mockCreatedLicense, license: {Users: existingUsers * 2}, }), - getClientLicenseOld: () => Promise.resolve({ - data: {Sku: 'Enterprise'}, - }), }, }; }); @@ -163,6 +160,7 @@ const initialState: DeepPartial = { EnableDeveloper: 'false', }, license: { + SkuName: productName, Sku: productName, SkuName: productName, Users: '50', @@ -240,7 +238,7 @@ const defaultSuccessForm: PurchaseForm = { city: 'Minneapolis', state: 'MN', zip: '55423', - seats: '10', + seats: '50', agree: true, }; @@ -257,11 +255,6 @@ function fillForm(form: PurchaseForm) { fireEvent.click(screen.getByText('I have read and agree', {exact: false})); } - // not changing the license seats number, because it is expected to be pre-filled, - // with the correct number of seats (current active users - current licensed seats, or 1 if the difference is 0). - - expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); - const completeButton = screen.getByText('Complete purchase'); if (form === defaultSuccessForm) { @@ -307,14 +300,24 @@ describe('SelfHostedExpansionModal Open', () => { expect(screen.getByText('You must add a seat to continue')).toBeVisible(); }); - it('happy path submit shows success screen', async () => { + it('happy path submit shows success screen when confirmation succeeds', async () => { renderWithIntlAndStore(
, initialState); expect(screen.getByText('Complete purchase')).toBeDisabled(); - const upgradeButton = fillForm(defaultSuccessForm); - expect(upgradeButton).toBeEnabled(); + const upgradeButton = fillForm(defaultSuccessForm); upgradeButton.click(); - await waitFor(() => expect(screen.getByText('You\'ve successfully updated your license seat count')).toBeTruthy(), {timeout: 1234}); + + 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 () => { 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 index 7eeedb5460..5fbd4d6ebc 100644 --- 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 @@ -50,7 +50,6 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants'; import Address from 'components/self_hosted_purchases/address'; import ChooseDifferentShipping from 'components/choose_different_shipping'; import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms'; -import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; import classNames from 'classnames'; export interface FormState { @@ -168,7 +167,6 @@ export function canSubmit(formState: FormState, progress: ValueOf(); const intl = useIntl(); const cardRef = useRef(null); @@ -457,7 +455,7 @@ export default function SelfHostedExpansionModal() { />
{ @@ -509,7 +507,10 @@ export default function SelfHostedExpansionModal() {
{((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( { + setFormState({...formState, submitting: false, error: '', succeeded: false}); + closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION); + }} /> )} {formState.submitting && ( 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 index 77916c9de7..b87592dd74 100644 --- 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 @@ -4,15 +4,12 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {NavLink} from 'react-router-dom'; - -import {useDispatch} from 'react-redux'; +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, ModalIdentifiers} from 'utils/constants'; +import {ConsolePages} from 'utils/constants'; import BackgroundSvg from 'components/common/svg_images_components/background_svg'; -import {closeModal} from 'actions/views/modals'; import './success_page.scss'; @@ -21,7 +18,7 @@ interface Props { } export default function SelfHostedExpansionSuccessPage(props: Props) { - const dispatch = useDispatch(); + const history = useHistory(); const titleText = ( Billing section of the system console.'} values={{ billing: (billingText: React.ReactNode) => ( - { + history.push(ConsolePages.BILLING_HISTORY); + props.onClose(); + }} > {billingText} - + ), }} /> @@ -72,7 +71,6 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { formattedButtonText={formattedButtonText} buttonHandler={() => { props.onClose(); - dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL)); }} />
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9c59ab386d..e07e5c70af 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2007,7 +2007,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', + BILLING_HISTORY: '/admin_console/billing/billing_history', }; export const WindowSizes = { From 02947aa0095a9631cb18738cb446a754b6f70ca3 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 10:43:36 -0400 Subject: [PATCH 045/113] fix type checks. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index df2d59eaf7..8f7e1dfcdb 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -61,10 +61,6 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) callerInfo: options.trackingLocation, }); - if (options.onClick) { - options.onClick(); - } - try { const result = await Client4.bootstrapSelfHostedSignup(); From d5d1b0317686da3cc22ecb3c730f5696713c0253 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 10:44:12 -0400 Subject: [PATCH 046/113] fix type checks (missed two). --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index 8f7e1dfcdb..a310c4538e 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -90,5 +90,5 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) } }, }; - }, [controlModal, options.onClick, options.trackingLocation]); + }, [controlModal, options.trackingLocation]); } From 34eece7462533f2ac3e73e4ab028a04068f0ba28 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 13:44:56 -0400 Subject: [PATCH 047/113] revert changes to overage users banner in favor of getting self hoste expansion modal pushed through faster. --- .../enterprise_edition_left_panel.test.tsx | 11 ++++++- .../overage_users_banner/index.tsx | 15 ++------- .../overage_users_banner_notice/index.tsx | 31 ++----------------- .../index.test.tsx | 1 - 4 files changed, 15 insertions(+), 43 deletions(-) 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 551af99212..3c8a8f4697 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 @@ -22,6 +22,15 @@ import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHoste 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', @@ -113,7 +122,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris 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).text().includes('LICENSED SEATS'); }); expect(item.text()).toContain('1,000'); diff --git a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx index ab53ac8be9..d22fe6389f 100644 --- a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx +++ b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx @@ -4,7 +4,6 @@ import React, {useMemo} from 'react'; import {FormattedMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; -import {useHistory} from 'react-router-dom'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {GlobalState} from 'types/store'; @@ -17,10 +16,9 @@ import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import {StatTypes, Preferences, AnnouncementBarTypes, ConsolePages} from 'utils/constants'; +import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants'; import './overage_users_banner.scss'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; type AdminHasDismissedItArgs = { preferenceName: string; @@ -58,8 +56,6 @@ const OverageUsersBanner = () => { const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn'; const prefixLicenseId = (license.Id || '').substring(0, 8); const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; - const history = useHistory(); - const isAirGapped = !useCWSAvailabilityCheck(); const overageByUsers = activeUsers - seatsPurchased; @@ -90,14 +86,7 @@ const OverageUsersBanner = () => { const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent) => { e.preventDefault(); trackEventFn('Self Serve'); - - if (isAirGapped) { - window.open(expandableLink(license.Id), '_blank'); - } - - if (isExpandable) { - history.push(`${ConsolePages.LICENSE}?action=show_expansion_modal`); - } + window.open(expandableLink(license.Id), '_blank'); }; const handleContactSalesClick = (e: React.MouseEvent) => { diff --git a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx index f72ec26388..ce1cd6a1c3 100644 --- a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx +++ b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx @@ -16,13 +16,10 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; -import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants'; +import {LicenseLinks, StatTypes, Preferences} from 'utils/constants'; import './overage_users_banner_notice.scss'; import ExternalLink from 'components/external_link'; -import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; -import {NavLink} from 'react-router-dom'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; type AdminHasDismissedArgs = { preferenceName: string; @@ -56,16 +53,15 @@ const OverageUsersBannerNotice = () => { const prefixLicenseId = (license.Id || '').substring(0, 8); const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; - const isAirGapped = !useCWSAvailabilityCheck(); const overageByUsers = activeUsers - seatsPurchased; const isOverageState = isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats; const hasPermission = isAdmin && isOverageState && !isCloud; const { cta, + expandableLink, trackEventFn, getRequestState, isExpandable, - expandableLink, } = useExpandOverageUsersCheck({ shouldRequest: hasPermission && !adminHasDismissed({overagePreferences, preferenceName}), licenseId: license.Id, @@ -73,8 +69,6 @@ const OverageUsersBannerNotice = () => { banner: 'invite modal', }); - const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'overage_user_banner_notice'}); - if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) { return null; } @@ -102,31 +96,12 @@ const OverageUsersBannerNotice = () => { const handleClick = () => { trackEventFn(isExpandable ? 'Self Serve' : 'Contact Sales'); - if (isExpandable) { - selfHostedExpansionModal.open(); - } }; - if (isAirGapped) { - window.open(expandableLink(license.Id), '_blank'); - } - - if (isExpandable) { - return ( - - {cta} - - ); - } - return ( {cta} 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 index 2d2931483e..8a6efee355 100644 --- 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 @@ -162,7 +162,6 @@ const initialState: DeepPartial = { license: { SkuName: productName, Sku: productName, - SkuName: productName, Users: '50', ExpiresAt: licenseExpiry.valueOf().toString(), }, From 451f169fea3de84226896269bdf7c981f3254773 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 14:12:28 -0400 Subject: [PATCH 048/113] use a more generic model for self hosted confirm payment method. --- server/channels/api4/hosted_customer.go | 3 +-- server/channels/einterfaces/cloud.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index b82fb8719f..31cc047085 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -177,8 +177,8 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { } var confirmResponse *model.SelfHostedSignupConfirmResponse + var confirm model.SelfHostedConfirmPaymentMethodRequest if expand { - var confirm model.SelfHostedExpansionConfirmPaymentMethodRequest err = json.Unmarshal(bodyBytes, &confirm) if err != nil { c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) @@ -187,7 +187,6 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { confirmResponse, err = c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) } else { - var confirm model.SelfHostedConfirmPaymentMethodRequest err = json.Unmarshal(bodyBytes, &confirm) if err != nil { c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) diff --git a/server/channels/einterfaces/cloud.go b/server/channels/einterfaces/cloud.go index 8d92474cc8..d8c261aa6d 100644 --- a/server/channels/einterfaces/cloud.go +++ b/server/channels/einterfaces/cloud.go @@ -37,7 +37,7 @@ type CloudInterface interface { BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) - ConfirmSelfHostedExpansion(req model.SelfHostedExpansionConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) + ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) ConfirmSelfHostedSignupLicenseApplication() error GetSelfHostedInvoices() ([]*model.Invoice, error) GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) From 4bd37013638986119628f50396f925c69e8893ec Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 14:15:11 -0400 Subject: [PATCH 049/113] remove new model in favor of making self hosted confirm payment method request more generic. --- model/hosted_customer.go | 10 +++------- server/channels/einterfaces/mocks/CloudInterface.go | 6 +++--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 2337a48569..f4cb8b999f 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -29,13 +29,9 @@ type SelfHostedCustomerForm struct { } type SelfHostedConfirmPaymentMethodRequest struct { - StripeSetupIntentID string `json:"stripe_setup_intent_id"` - Subscription CreateSubscriptionRequest `json:"subscription"` -} - -type SelfHostedExpansionConfirmPaymentMethodRequest struct { - StripeSetupIntentID string `json:"stripe_setup_intent_id"` - ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + Subscription *CreateSubscriptionRequest `json:"subscription"` + ExpandRequest *SelfHostedExpansionRequest `json:"expand_request"` } // SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license. diff --git a/server/channels/einterfaces/mocks/CloudInterface.go b/server/channels/einterfaces/mocks/CloudInterface.go index b287dc0680..9b133ce079 100644 --- a/server/channels/einterfaces/mocks/CloudInterface.go +++ b/server/channels/einterfaces/mocks/CloudInterface.go @@ -89,11 +89,11 @@ func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest * } // ConfirmSelfHostedExpansion provides a mock function with given fields: req, requesterEmail -func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedExpansionConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { +func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { ret := _m.Called(req, requesterEmail) var r0 *model.SelfHostedSignupConfirmResponse - if rf, ok := ret.Get(0).(func(model.SelfHostedExpansionConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { + if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { r0 = rf(req, requesterEmail) } else { if ret.Get(0) != nil { @@ -102,7 +102,7 @@ func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedExpansi } var r1 error - if rf, ok := ret.Get(1).(func(model.SelfHostedExpansionConfirmPaymentMethodRequest, string) error); ok { + if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok { r1 = rf(req, requesterEmail) } else { r1 = ret.Error(1) From dab14be745b97a8b1934a8e73ffa6a0103f9053d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 14:44:33 -0400 Subject: [PATCH 050/113] fix mocks --- server/channels/einterfaces/mocks/CloudInterface.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/channels/einterfaces/mocks/CloudInterface.go b/server/channels/einterfaces/mocks/CloudInterface.go index d308239e23..74236d2038 100644 --- a/server/channels/einterfaces/mocks/CloudInterface.go +++ b/server/channels/einterfaces/mocks/CloudInterface.go @@ -99,6 +99,10 @@ func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirm ret := _m.Called(req, requesterEmail) var r0 *model.SelfHostedSignupConfirmResponse + var r1 error + if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) (*model.SelfHostedSignupConfirmResponse, error)); ok { + return rf(req, requesterEmail) + } if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { r0 = rf(req, requesterEmail) } else { @@ -107,7 +111,6 @@ func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirm } } - var r1 error if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok { r1 = rf(req, requesterEmail) } else { From 183e3c6033a70e079f447372de0d806fc5cc0cdc Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:00:18 -0400 Subject: [PATCH 051/113] Update index.tsx Fix success modal not closing. --- .../self_hosted_purchases/self_hosted_expansion_modal/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 5fbd4d6ebc..88efd97956 100644 --- 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 @@ -509,7 +509,7 @@ export default function SelfHostedExpansionModal() { { setFormState({...formState, submitting: false, error: '', succeeded: false}); - closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); }} /> )} From ecbdd917879ca2d1ed9b49ba6cd9e405b75deebb Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:01:54 -0400 Subject: [PATCH 052/113] Update enterprise_edition_left_panel.tsx Fix handle click `+Add Seats` button to ensure checks for expansion availability look at service settings OR expansion availability are --- .../enterprise_edition/enterprise_edition_left_panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b2616d9453..7cc45db259 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 @@ -100,7 +100,7 @@ const EnterpriseEditionLeftPanel = ({ ); const handleClickAddSeats = () => { - if (!isSelfHostedExpansionEnabled && !canExpand) { + if (!isSelfHostedExpansionEnabled || !canExpand) { window.open(expandableLink(unsanitizedLicense.Id), '_blank'); } else { selfHostedExpansionModal.open(); From 374166bba9790165a4b974986c2c7acd9b1205f9 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 17 Apr 2023 10:17:48 -0500 Subject: [PATCH 053/113] add ci steps --- .github/workflows/channels-ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/channels-ci.yml b/.github/workflows/channels-ci.yml index d6a1a6258c..3321fd717f 100644 --- a/.github/workflows/channels-ci.yml +++ b/.github/workflows/channels-ci.yml @@ -83,6 +83,16 @@ jobs: npm run mmjstool -- i18n clean-empty --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir --check npm run mmjstool -- i18n check-empty-src --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir rm -rf tmp + - name: ci/lint + working-directory: webapp/boards + run: | + npm run i18n-extract + git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/boards && npm run i18n-extract\" and commit the changes in webapp/boards/i18n/en.json." && exit 1) + - name: ci/lint + working-directory: webapp/playbooks + run: | + npm run i18n-extract + git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/playbooks && npm run i18n-extract\" and commit the changes in webapp/playbooks/i18n/en.json." && exit 1) check-types: runs-on: ubuntu-22.04 defaults: From 4779700a06b3d74099592a247159230b3e418c44 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 17 Apr 2023 10:29:12 -0500 Subject: [PATCH 054/113] test ci extract lint --- webapp/boards/src/components/addContentMenuItem.tsx | 2 +- webapp/playbooks/src/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/boards/src/components/addContentMenuItem.tsx b/webapp/boards/src/components/addContentMenuItem.tsx index 1e147b2563..335a6e4c9d 100644 --- a/webapp/boards/src/components/addContentMenuItem.tsx +++ b/webapp/boards/src/components/addContentMenuItem.tsx @@ -42,7 +42,7 @@ const AddContentMenuItem = (props: Props): JSX.Element => { newBlock.boardId = card.boardId const typeName = handler.getDisplayText(intl) - const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) + const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type} __ci-test__'}, {type: typeName}) const afterRedo = async (nb: Block) => { const contentOrder = card.fields.contentOrder.slice() diff --git a/webapp/playbooks/src/index.tsx b/webapp/playbooks/src/index.tsx index d72b244f2f..6e9f4b1bbf 100644 --- a/webapp/playbooks/src/index.tsx +++ b/webapp/playbooks/src/index.tsx @@ -213,7 +213,7 @@ export default class Plugin { const siteStats = await fetchSiteStats(); return { playbook_count: { - name: , + name: , id: 'total_playbooks', icon: 'fa-book', // font-awesome-4.7.0 handler value: siteStats?.total_playbooks, From 01dede9b1991419f654ef39525b3b5e377d2b2ca Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 17 Apr 2023 11:12:40 -0500 Subject: [PATCH 055/113] Revert "test ci extract lint" This reverts commit 4779700a06b3d74099592a247159230b3e418c44. --- webapp/boards/src/components/addContentMenuItem.tsx | 2 +- webapp/playbooks/src/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/boards/src/components/addContentMenuItem.tsx b/webapp/boards/src/components/addContentMenuItem.tsx index 335a6e4c9d..1e147b2563 100644 --- a/webapp/boards/src/components/addContentMenuItem.tsx +++ b/webapp/boards/src/components/addContentMenuItem.tsx @@ -42,7 +42,7 @@ const AddContentMenuItem = (props: Props): JSX.Element => { newBlock.boardId = card.boardId const typeName = handler.getDisplayText(intl) - const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type} __ci-test__'}, {type: typeName}) + const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) const afterRedo = async (nb: Block) => { const contentOrder = card.fields.contentOrder.slice() diff --git a/webapp/playbooks/src/index.tsx b/webapp/playbooks/src/index.tsx index 6e9f4b1bbf..d72b244f2f 100644 --- a/webapp/playbooks/src/index.tsx +++ b/webapp/playbooks/src/index.tsx @@ -213,7 +213,7 @@ export default class Plugin { const siteStats = await fetchSiteStats(); return { playbook_count: { - name: , + name: , id: 'total_playbooks', icon: 'fa-book', // font-awesome-4.7.0 handler value: siteStats?.total_playbooks, From 5b42689529e156d5c70c3be51d14a3ef26759dbb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 14:24:58 -0400 Subject: [PATCH 056/113] Address code review comments (styling, clean-up css, re-org imports, math errors, etc). --- .../src/components/outlined_input/index.tsx | 26 ++++++++ .../error_page.tsx | 2 +- .../expansion_card.scss | 10 --- .../expansion_card.tsx | 63 +++++++++---------- .../self_hosted_expansion_modal/index.tsx | 62 +++++++++--------- .../self_hosted_expansion_modal.scss | 3 - .../submitting.tsx | 3 +- .../success_page.scss | 1 - .../success_page.tsx | 8 +-- webapp/channels/src/i18n/en.json | 1 - 10 files changed, 90 insertions(+), 89 deletions(-) create mode 100644 webapp/channels/src/components/outlined_input/index.tsx 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 + * 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; } @@ -94,14 +88,12 @@ .totalCostWarning > span:first-child { color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; font-size: 14px; font-weight: 700; } .totalCostWarning > span:last-child { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); - font-family: 'Open Sans'; font-size: 12px; } @@ -119,7 +111,6 @@ height: 35px; margin-bottom: 15px; color: var(--dnd-indicator); - font-family: 'Open Sans'; font-size: 12px; font-weight: 600; text-align: right; @@ -134,7 +125,6 @@ &__ChargedTodayDisclaimer { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); - font-family: 'Open Sans'; 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 index 3cf1243e17..aa9a5edfb8 100644 --- 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 @@ -1,21 +1,22 @@ // 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 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 {DocLinks} from 'utils/constants'; + 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'; -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 MAX_TRANSACTION_VALUE = 1_000_000 - 1; @@ -29,6 +30,7 @@ interface Props { } 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'); @@ -46,14 +48,11 @@ export default function SelfHostedExpansionCard(props: Props) { }; const getCostPerUser = () => { - if (isNaN(additionalSeats)) { - return 0; - } const monthsUntilExpiry = getMonthsUntilExpiry(); return costPerMonth * monthsUntilExpiry; }; - const getTotal = () => { + const getPaymentTotal = () => { if (isNaN(additionalSeats)) { return 0; } @@ -63,25 +62,29 @@ export default function SelfHostedExpansionCard(props: Props) { // 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 + // (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat const getMaximumAdditionalSeats = () => { if (currentProduct === null) { return 0; } - const currentPaymentPrice = costPerMonth * props.licensedSeats; + const currentPaymentPrice = costPerMonth * props.licensedSeats * 12; const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; - const remainingSeats = Math.floor(remainingTransactionLimit / costPerMonth); + const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12)); return Math.max(0, remainingSeats); }; - const maxAdditionalSeats = getMaximumAdditionalSeats(); const handleNewSeatsInputChange = (e: React.ChangeEvent) => { - setOverMaxSeats(false); - const requestedSeats = parseInt(e.target.value, 10); + if (requestedSeats <= 0) { + e.preventDefault(); + return; + } + + setOverMaxSeats(false); + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; setOverMaxSeats(overMaxAdditionalSeats); @@ -91,6 +94,10 @@ export default function SelfHostedExpansionCard(props: Props) { props.updateSeats(finalSeatCount); }; + const formatCurrency = (value: number) => { + return intl.formatNumber(value, {style: 'currency', currency: 'USD'}); + }; + return (
@@ -158,16 +165,6 @@ export default function SelfHostedExpansionCard(props: Props) { }} /> } - {maxAdditionalSeats === 0 && - , - warningIcon: , - }} - /> - }
@@ -179,15 +176,15 @@ export default function SelfHostedExpansionCard(props: Props) {
- {'$' + getCostPerUser().toFixed(2)} + {formatCurrency(getCostPerUser())}
- {'$' + getTotal().toFixed(2)} + {formatCurrency(getPaymentTotal()) }
From 093a17db7074da96502d1966cfbbdb84510c09f0 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 15:37:09 -0400 Subject: [PATCH 058/113] lint. --- .../self_hosted_expansion_modal/expansion_card.tsx | 4 ++-- .../self_hosted_expansion_modal/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 3bd91c348d..b6440d986b 100644 --- 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 @@ -76,7 +76,7 @@ export default function SelfHostedExpansionCard(props: Props) { const maxAdditionalSeats = getMaximumAdditionalSeats(); const handleNewSeatsInputChange = (e: React.ChangeEvent) => { - let requestedSeats = parseInt(e.target.value, 10); + const requestedSeats = parseInt(e.target.value, 10); if (!isNaN(requestedSeats) && requestedSeats <= 0) { e.preventDefault(); @@ -160,7 +160,7 @@ export default function SelfHostedExpansionCard(props: Props) { defaultMessage='{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license' values={{ warningIcon: , - minimumSeats: props.minimumSeats + minimumSeats: props.minimumSeats, }} /> } 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 index 48b1e276f8..b66a81f517 100644 --- 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 @@ -176,7 +176,7 @@ export default function SelfHostedExpansionModal() { 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 [requestedSeats, setRequestedSeats] = useState(minimumSeats); const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); const stripeRef = useLoadStripe(stripeLoadHint); From 7bfebcc80a7c77f5c4def02f75f4d6de42b7d9f4 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 15:38:52 -0400 Subject: [PATCH 059/113] Change confirm expand client request to hit a separate endpoint from self hosted purchases confirm. --- webapp/platform/client/src/client4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index b194c71946..47e6741b0e 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3899,7 +3899,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( - `${this.getHostedCustomerRoute()}/confirm?expand=true`, + `${this.getHostedCustomerRoute()}/confirm-expand`, {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From a1605d51f6f29e8eb3e16362cbbc5dcd2f4abbb8 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 16:02:00 -0400 Subject: [PATCH 060/113] Remove checks for self hosted expansion featue flags/configs, add copy of the self hosted confirm handler specifically for expansions. --- server/channels/api4/hosted_customer.go | 124 +++++++++++++++++------- 1 file changed, 91 insertions(+), 33 deletions(-) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index 53204e58ad..9942dcabf7 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -32,6 +32,8 @@ func (api *API) InitHostedCustomer() { api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST") // POST /api/v4/hosted_customer/confirm api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST") + // POST /api.v4/hosted_customer/confirm-expand + api.BaseRoutes.HostedCustomer.Handle("/confirm-expand", api.APISessionRequired(selfHostedConfirmExpand)).Methods("POST") // GET /api/v4/hosted_customer/invoices api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET") // GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf @@ -68,18 +70,9 @@ func checkSelfHostedPurchaseEnabled(c *Context) bool { return enabled != nil && *enabled } -func checkSelfHostedExpansionEnabled(c *Context) bool { - config := c.App.Config() - if config == nil { - return false - } - enabled := config.ServiceSettings.SelfHostedExpansion - return enabled != nil && *enabled -} - func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { const where = "Api4.selfHostedBootstrap" - if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } @@ -115,7 +108,7 @@ func selfHostedCustomer(c *Context, w http.ResponseWriter, r *http.Request) { if c.Err != nil { return } - if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } @@ -158,44 +151,31 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { if c.Err != nil { return } - if !checkSelfHostedPurchaseEnabled(c) && !checkSelfHostedExpansionEnabled(c) { + if !checkSelfHostedPurchaseEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } - expand := r.URL.Query().Get("expand") == "true" - bodyBytes, err := io.ReadAll(r.Body) if err != nil { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } + var confirm model.SelfHostedConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) if userErr != nil { c.Err = userErr return } - var confirmResponse *model.SelfHostedSignupConfirmResponse - var confirm model.SelfHostedConfirmPaymentMethodRequest - if expand { - err = json.Unmarshal(bodyBytes, &confirm) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - confirmResponse, err = c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) - } else { - err = json.Unmarshal(bodyBytes, &confirm) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - confirmResponse, err = c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) - } + confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) if err != nil { if confirmResponse != nil { c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) @@ -348,3 +328,81 @@ func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Requ ReturnStatusOK(w) } + +func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedConfirmExpand" + + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + if !checkSelfHostedPurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + var confirm model.SelfHostedConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + + confirmResponse, err := c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + + if err.Error() == fmt.Sprintf("%d", http.StatusUnprocessableEntity) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) + return + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) + + // dealing with an AppError + if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{ + License: utils.GetClientLicense(license), + Progress: confirmResponse.Progress, + }) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + go func() { + err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication() + if err != nil { + c.Logger.Warn("Unable to confirm license application", mlog.Err(err)) + } + }() + + _, _ = w.Write(clientResponse) +} From f613c655e7af639c06f4beb8b5245dba9b450ecc Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 16:11:43 -0400 Subject: [PATCH 061/113] remove self hosted expansion flag in favor of grouping hosted expansion with hosted purhcase and it's flag. --- e2e-tests/playwright/support/server/default_config.ts | 1 - model/config.go | 5 ----- server/platform/services/telemetry/telemetry.go | 1 - .../enterprise_edition_left_panel.test.tsx | 2 +- .../enterprise_edition/enterprise_edition_left_panel.tsx | 6 +++--- webapp/platform/types/src/config.ts | 1 - 6 files changed, 4 insertions(+), 12 deletions(-) diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index a82775d783..a5b92aeb89 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/model/config.go b/model/config.go index 4868229bbf..95c0b6514e 100644 --- a/model/config.go +++ b/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 a214911f20..157db35f48 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/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 3c8a8f4697..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 @@ -69,7 +69,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris admin: { config: { ServiceSettings: { - SelfHostedExpansion: true, + SelfHostedPurchase: true, }, }, }, 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 7cc45db259..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 @@ -59,13 +59,13 @@ const EnterpriseEditionLeftPanel = ({ const canExpand = useCanSelfHostedExpand(); const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); const expandableLink = useSelector(getExpandSeatsLink); - const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; + const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; const query = useQuery(); const actionQueryParam = query.get('action'); useEffect(() => { - if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { + if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) { selfHostedExpansionModal.open(); query.set('action', ''); } @@ -100,7 +100,7 @@ const EnterpriseEditionLeftPanel = ({ ); const handleClickAddSeats = () => { - if (!isSelfHostedExpansionEnabled || !canExpand) { + if (!isSelfHostedPurchaseEnabled || !canExpand) { window.open(expandableLink(unsanitizedLicense.Id), '_blank'); } else { selfHostedExpansionModal.open(); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index a9053a7a51..0a56b79b6a 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -369,7 +369,6 @@ export type ServiceSettings = { EnableCustomGroups: boolean; SelfHostedPurchase: boolean; AllowSyncedDrafts: boolean; - SelfHostedExpansion: boolean; }; export type TeamSettings = { From dd314373be184b69f313c1899f19e5897c4d2ce5 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 16:26:43 -0400 Subject: [PATCH 062/113] i18n. --- webapp/channels/src/i18n/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 92034bf68e..cde81b5186 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4758,6 +4758,7 @@ "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", From 2f8e9f16e3863c60b5e7fbefc667e8016adf43bf Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 12:04:23 -0400 Subject: [PATCH 063/113] update and fix tests. --- .../index.test.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) 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 index 8a6efee355..2915d932de 100644 --- 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 @@ -283,22 +283,6 @@ describe('SelfHostedExpansionModal Open', () => { fillForm(defaultSuccessForm); }); - it('disables expansion if too few seats or no seats entered', () => { - renderWithIntlAndStore(
, 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 when confirmation succeeds', async () => { renderWithIntlAndStore(
, initialState); expect(screen.getByText('Complete purchase')).toBeDisabled(); @@ -336,13 +320,49 @@ 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 || '0', 10); + 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); @@ -366,7 +386,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount).toHaveTextContent('$' + expectedTotalCost); + expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost)); }); }); From b95e546482347b776f8afef36769f48b69c4af12 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Tue, 18 Apr 2023 11:29:31 -0500 Subject: [PATCH 064/113] step names --- .github/workflows/channels-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/channels-ci.yml b/.github/workflows/channels-ci.yml index 3321fd717f..7d71862ba7 100644 --- a/.github/workflows/channels-ci.yml +++ b/.github/workflows/channels-ci.yml @@ -83,12 +83,12 @@ jobs: npm run mmjstool -- i18n clean-empty --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir --check npm run mmjstool -- i18n check-empty-src --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir rm -rf tmp - - name: ci/lint + - name: ci/lint-boards working-directory: webapp/boards run: | npm run i18n-extract git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/boards && npm run i18n-extract\" and commit the changes in webapp/boards/i18n/en.json." && exit 1) - - name: ci/lint + - name: ci/lint-playbooks working-directory: webapp/playbooks run: | npm run i18n-extract From f0ed400732d226ee66e257c97bb28d307396cb22 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 13:47:35 -0400 Subject: [PATCH 065/113] remove use of reflection to detect app err. --- server/channels/api4/hosted_customer.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index 5a6ceda767..5affb6c1b1 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -188,9 +188,8 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) return } - license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) - // dealing with an AppError - if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { + license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) + if appErr != nil { if confirmResponse != nil { c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) } From 7bdc5a4a39d6e6bec4247a711c5be390caab048a Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 16:27:37 -0400 Subject: [PATCH 066/113] Fix rhs expansion card resize in resized windows, fix credit card title section being overlapped with the credit card number input hint. --- .../self_hosted_expansion_modal/expansion_card.scss | 2 +- .../self_hosted_expansion_modal.scss | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 089f296671..e6910940f0 100644 --- 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 @@ -1,6 +1,6 @@ .SelfHostedExpansionRHSCard { display: flex; - max-width: 280px; + width: 280px; flex-direction: column; &__Content { 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 index 5a98adfbbf..1938890b37 100644 --- 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 @@ -58,7 +58,8 @@ } .section-title { - margin-bottom: 24px; + display: block; + margin-bottom: 10px; color: rgba(var(--center-channel-color-rgb), 0.72); font-size: 16px; font-weight: 600; From c9e081d0b1781ee724dec87e0d6adeca893be6f1 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 17:03:20 -0400 Subject: [PATCH 067/113] Ensure submitting and success screen icon messages are at the same position, ensure no overflow at the bottom of the page, try to hide overflow peeking through on the right side of the page. --- .../self_hosted_expansion_modal/submitting.tsx | 2 ++ .../self_hosted_expansion_modal/submitting_page.scss | 6 ++++++ .../self_hosted_expansion_modal/success_page.scss | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss 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 index 9dc1ce0620..e87af1842e 100644 --- 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 @@ -13,6 +13,8 @@ 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) { 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..7171883350 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss @@ -0,0 +1,6 @@ +.submitting { + overflow: hidden; + .processing { + margin-top: 163px; + } +} \ No newline at end of file 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 index 8986942706..b32204d8f3 100644 --- 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 @@ -15,5 +15,8 @@ } .self_hosted_expansion_success { - margin-top: 163px; + overflow: hidden; + .selfHostedExpansionModal__success { + margin-top: 163px; + } } From 42f457fec24c32178647b3e94a03b37db250f110 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 17:16:06 -0400 Subject: [PATCH 068/113] lint. --- .../self_hosted_expansion_modal/submitting.tsx | 2 +- .../self_hosted_expansion_modal/submitting_page.scss | 3 ++- .../self_hosted_expansion_modal/success_page.scss | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) 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 index e87af1842e..7eafc6c9b1 100644 --- 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 @@ -13,7 +13,7 @@ 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' +import './submitting_page.scss'; function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { const intl = useIntl(); 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 index 7171883350..46179472b8 100644 --- 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 @@ -1,6 +1,7 @@ .submitting { overflow: hidden; + .processing { margin-top: 163px; } -} \ No newline at end of file +} 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 index b32204d8f3..522384347e 100644 --- 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 @@ -16,6 +16,7 @@ .self_hosted_expansion_success { overflow: hidden; + .selfHostedExpansionModal__success { margin-top: 163px; } From 3f0ac142c4ef47e02622c8fd2e0a87de579f9166 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Wed, 19 Apr 2023 16:20:17 +0530 Subject: [PATCH 069/113] MM-49603 : Remove fetching of deleted channels on page load (#22981) - channel request types removed from fetchMyChannelsAndMembersREST - removed isMinimumServerVersion check from that above action call, which is adding the query to include the archived channels --- .../mattermost-redux/src/actions/channels.ts | 51 +++++++------------ 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index f5afb85aa2..f31c122014 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -4,6 +4,18 @@ import {AnyAction} from 'redux'; import {batchActions} from 'redux-batched-actions'; +import {ServerError} from '@mattermost/types/errors'; +import { + Channel, + ChannelNotifyProps, + ChannelMembership, + ChannelModerationPatch, + ChannelsWithTotalCount, + ChannelSearchOpts, + ServerChannel, +} from '@mattermost/types/channels'; +import {PreferenceType} from '@mattermost/types/preferences'; + import {ChannelTypes, PreferenceTypes, UserTypes} from 'mattermost-redux/action_types'; import {Client4} from 'mattermost-redux/client'; @@ -19,18 +31,12 @@ import { getRedirectChannelNameForTeam, isManuallyUnread, } from 'mattermost-redux/selectors/entities/channels'; -import {getConfig, getServerVersion} from 'mattermost-redux/selectors/entities/general'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {ActionFunc, ActionResult, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; -import {getChannelsIdForTeam, getChannelByName} from 'mattermost-redux/utils/channel_utils'; - -import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; - -import {Channel, ChannelNotifyProps, ChannelMembership, ChannelModerationPatch, ChannelsWithTotalCount, ChannelSearchOpts} from '@mattermost/types/channels'; - -import {PreferenceType} from '@mattermost/types/preferences'; +import {getChannelByName} from 'mattermost-redux/utils/channel_utils'; import {General, Preferences} from '../constants'; @@ -462,52 +468,33 @@ export function getChannelTimezones(channelId: string): ActionFunc { }; } -export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc { +export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc<{channels: ServerChannel[]; channelMembers: ChannelMembership[]}> { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - dispatch({ - type: ChannelTypes.CHANNELS_REQUEST, - data: null, - }); - let channels; let channelMembers; - const state = getState(); - const shouldFetchArchived = isMinimumServerVersion(getServerVersion(state), 5, 21); try { [channels, channelMembers] = await Promise.all([ - Client4.getMyChannels(teamId, shouldFetchArchived), + Client4.getMyChannels(teamId), Client4.getMyChannelMembers(teamId), ]); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); - dispatch({type: ChannelTypes.CHANNELS_FAILURE, error}); dispatch(logError(error)); - return {error}; + return {error: error as ServerError}; } - const {currentUserId} = state.entities.users; - const {currentChannelId} = state.entities.channels; - dispatch(batchActions([ { type: ChannelTypes.RECEIVED_CHANNELS, teamId, data: channels, - currentChannelId, - }, - { - type: ChannelTypes.CHANNELS_SUCCESS, }, { type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, data: channelMembers, - sync: !shouldFetchArchived, - channels, - remove: getChannelsIdForTeam(state, teamId), - currentUserId, - currentChannelId, }, ])); + const roles = new Set(); for (const member of channelMembers) { for (const role of member.roles.split(' ')) { @@ -518,7 +505,7 @@ export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc { dispatch(loadRolesIfNeeded(roles)); } - return {data: {channels, members: channelMembers}}; + return {data: {channels, channelMembers}}; }; } From 5d5c1d90bfe1cebe117b5221487cf73b0dc1ad4c Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Wed, 19 Apr 2023 16:21:10 +0530 Subject: [PATCH 070/113] MM-50123 : Identify causes of removal of channel and channel members re fetching on team switch (#22984) --- .../channels/src/actions/channel_actions.ts | 21 ++++++++----------- .../team_controller/actions/index.ts | 17 +-------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index b3c9ffb156..c800d0fda1 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -254,25 +254,22 @@ export function fetchChannelsAndMembers(teamId: Team['id'] = ''): ActionFunc<{ch teamId, data: channels, }); - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, - data: channelMembers, - }); - actions.push({ - type: RoleTypes.RECEIVED_ROLES, - data: roles, - }); } else { actions.push({ type: ChannelTypes.RECEIVED_ALL_CHANNELS, data: channels, }); - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, - data: channelMembers, - }); } + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, + data: channelMembers, + }); + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: roles, + }); + await dispatch(batchActions(actions)); return {data: {channels, channelMembers, roles}}; diff --git a/webapp/channels/src/components/team_controller/actions/index.ts b/webapp/channels/src/components/team_controller/actions/index.ts index 38977f2678..e1717d0dbc 100644 --- a/webapp/channels/src/components/team_controller/actions/index.ts +++ b/webapp/channels/src/components/team_controller/actions/index.ts @@ -4,10 +4,9 @@ import {ActionFunc} from 'mattermost-redux/types/actions'; import {getTeamByName, selectTeam} from 'mattermost-redux/actions/teams'; import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; -import {fetchMyChannelsAndMembersREST} from 'mattermost-redux/actions/channels'; import {getGroups, getAllGroupsAssociatedToChannelsInTeam, getAllGroupsAssociatedToTeam, getGroupsByUserIdPaginated} from 'mattermost-redux/actions/groups'; import {logError} from 'mattermost-redux/actions/errors'; -import {isCustomGroupsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; @@ -15,7 +14,6 @@ import {isSuccess} from 'types/actions'; import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; import {addUserToTeam} from 'actions/team_actions'; -import {fetchChannelsAndMembers} from 'actions/channel_actions'; import LocalStorageStore from 'stores/local_storage_store'; @@ -30,19 +28,6 @@ export function initializeTeam(team: Team): ActionFunc { const currentUser = getCurrentUser(state); LocalStorageStore.setPreviousTeamId(currentUser.id, team.id); - const graphQLEnabled = isGraphQLEnabled(state); - try { - if (graphQLEnabled) { - await dispatch(fetchChannelsAndMembers(team.id)); - } else { - await dispatch(fetchMyChannelsAndMembersREST(team.id)); - } - } catch (error) { - forceLogoutIfNecessary(error as ServerError, dispatch, getState); - dispatch(logError(error as ServerError)); - return {error: error as ServerError}; - } - dispatch(loadStatusesForChannelAndSidebar()); const license = getLicense(state); From c34a50a6c710ba116baebfd03b624eb0dc110b2a Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Wed, 19 Apr 2023 17:03:18 +0530 Subject: [PATCH 071/113] MM-50427: Make MM survive DB replica outage (#22888) We monitor the health of DB replicas, and on a fatal error, take them out of the pool. On a separate goroutine, we keep pinging the unhealthy replicas, and on getting a good response back, we add them back to the pool. https://mattermost.atlassian.net/browse/MM-50427 ```release-note Mattermost is now resilient against DB replica outages and will dynamically choose a replica if it's alive. Also added a config parameter ReplicaMonitorIntervalSeconds whose default value is 5. This controls how frequently unhealthy replicas will be monitored for liveness check. ``` Co-authored-by: Mattermost Build --- .../boards/services/store/sqlstore/migrate.go | 5 +- server/channels/einterfaces/metrics.go | 1 + .../einterfaces/mocks/MetricsInterface.go | 5 + .../channels/store/sqlstore/sqlx_wrapper.go | 73 ++++-- .../store/sqlstore/sqlx_wrapper_test.go | 11 +- server/channels/store/sqlstore/store.go | 225 ++++++++++++------ server/channels/store/sqlstore/store_test.go | 18 +- server/channels/store/store.go | 3 - .../channels/store/storetest/mocks/Store.go | 16 -- server/channels/store/storetest/settings.go | 1 + server/channels/testlib/helper.go | 2 +- server/model/config.go | 5 + .../platform/services/telemetry/telemetry.go | 1 + 13 files changed, 245 insertions(+), 121 deletions(-) diff --git a/server/boards/services/store/sqlstore/migrate.go b/server/boards/services/store/sqlstore/migrate.go index 1c876a5168..63b4665490 100644 --- a/server/boards/services/store/sqlstore/migrate.go +++ b/server/boards/services/store/sqlstore/migrate.go @@ -70,7 +70,10 @@ func (s *SQLStore) getMigrationConnection() (*sql.DB, error) { } *settings.DriverName = s.dbType - db := sqlstore.SetupConnection("master", connectionString, &settings) + db, err := sqlstore.SetupConnection("master", connectionString, &settings, sqlstore.DBPingAttempts) + if err != nil { + return nil, err + } return db, nil } diff --git a/server/channels/einterfaces/metrics.go b/server/channels/einterfaces/metrics.go index 06f44f7b66..c44af2a3c5 100644 --- a/server/channels/einterfaces/metrics.go +++ b/server/channels/einterfaces/metrics.go @@ -13,6 +13,7 @@ import ( type MetricsInterface interface { Register() RegisterDBCollector(db *sql.DB, name string) + UnregisterDBCollector(db *sql.DB, name string) IncrementPostCreate() IncrementWebhookPost() diff --git a/server/channels/einterfaces/mocks/MetricsInterface.go b/server/channels/einterfaces/mocks/MetricsInterface.go index 0d6f799ee5..06f568546a 100644 --- a/server/channels/einterfaces/mocks/MetricsInterface.go +++ b/server/channels/einterfaces/mocks/MetricsInterface.go @@ -319,6 +319,11 @@ func (_m *MetricsInterface) SetReplicaLagTime(node string, value float64) { _m.Called(node, value) } +// UnregisterDBCollector provides a mock function with given fields: db, name +func (_m *MetricsInterface) UnregisterDBCollector(db *sql.DB, name string) { + _m.Called(db, name) +} + type mockConstructorTestingTNewMetricsInterface interface { mock.TestingT Cleanup(func()) diff --git a/server/channels/store/sqlstore/sqlx_wrapper.go b/server/channels/store/sqlstore/sqlx_wrapper.go index 0dab579512..e8d771cada 100644 --- a/server/channels/store/sqlstore/sqlx_wrapper.go +++ b/server/channels/store/sqlstore/sqlx_wrapper.go @@ -6,9 +6,12 @@ package sqlstore import ( "context" "database/sql" + "errors" + "net" "regexp" "strconv" "strings" + "sync/atomic" "time" "unicode" @@ -66,14 +69,18 @@ type sqlxDBWrapper struct { *sqlx.DB queryTimeout time.Duration trace bool + isOnline *atomic.Bool } func newSqlxDBWrapper(db *sqlx.DB, timeout time.Duration, trace bool) *sqlxDBWrapper { - return &sqlxDBWrapper{ + w := &sqlxDBWrapper{ DB: db, queryTimeout: timeout, trace: trace, + isOnline: &atomic.Bool{}, } + w.isOnline.Store(true) + return w } func (w *sqlxDBWrapper) Stats() sql.DBStats { @@ -83,19 +90,19 @@ func (w *sqlxDBWrapper) Stats() sql.DBStats { func (w *sqlxDBWrapper) Beginx() (*sqlxTxWrapper, error) { tx, err := w.DB.Beginx() if err != nil { - return nil, err + return nil, w.checkErr(err) } - return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil + return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil } func (w *sqlxDBWrapper) BeginXWithIsolation(opts *sql.TxOptions) (*sqlxTxWrapper, error) { tx, err := w.DB.BeginTxx(context.Background(), opts) if err != nil { - return nil, err + return nil, w.checkErr(err) } - return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil + return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil } func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error { @@ -109,7 +116,7 @@ func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error { }(time.Now()) } - return w.DB.GetContext(ctx, dest, query, args...) + return w.checkErr(w.DB.GetContext(ctx, dest, query, args...)) } func (w *sqlxDBWrapper) GetBuilder(dest any, builder Builder) error { @@ -134,7 +141,7 @@ func (w *sqlxDBWrapper) NamedExec(query string, arg any) (sql.Result, error) { }(time.Now()) } - return w.DB.NamedExecContext(ctx, query, arg) + return w.checkErrWithResult(w.DB.NamedExecContext(ctx, query, arg)) } func (w *sqlxDBWrapper) Exec(query string, args ...any) (sql.Result, error) { @@ -161,7 +168,7 @@ func (w *sqlxDBWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er }(time.Now()) } - return w.DB.ExecContext(context.Background(), query, args...) + return w.checkErrWithResult(w.DB.ExecContext(context.Background(), query, args...)) } // ExecRaw is like Exec but without any rebinding of params. You need to pass @@ -176,7 +183,7 @@ func (w *sqlxDBWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { }(time.Now()) } - return w.DB.ExecContext(ctx, query, args...) + return w.checkErrWithResult(w.DB.ExecContext(ctx, query, args...)) } func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { @@ -192,7 +199,7 @@ func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { }(time.Now()) } - return w.DB.NamedQueryContext(ctx, query, arg) + return w.checkErrWithRows(w.DB.NamedQueryContext(ctx, query, arg)) } func (w *sqlxDBWrapper) QueryRowX(query string, args ...any) *sqlx.Row { @@ -220,7 +227,7 @@ func (w *sqlxDBWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) { }(time.Now()) } - return w.DB.QueryxContext(ctx, query, args) + return w.checkErrWithRows(w.DB.QueryxContext(ctx, query, args)) } func (w *sqlxDBWrapper) Select(dest any, query string, args ...any) error { @@ -238,7 +245,7 @@ func (w *sqlxDBWrapper) SelectCtx(ctx context.Context, dest any, query string, a }(time.Now()) } - return w.DB.SelectContext(ctx, dest, query, args...) + return w.checkErr(w.DB.SelectContext(ctx, dest, query, args...)) } func (w *sqlxDBWrapper) SelectBuilder(dest any, builder Builder) error { @@ -254,13 +261,15 @@ type sqlxTxWrapper struct { *sqlx.Tx queryTimeout time.Duration trace bool + dbw *sqlxDBWrapper } -func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool) *sqlxTxWrapper { +func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool, dbw *sqlxDBWrapper) *sqlxTxWrapper { return &sqlxTxWrapper{ Tx: tx, queryTimeout: timeout, trace: trace, + dbw: dbw, } } @@ -275,7 +284,7 @@ func (w *sqlxTxWrapper) Get(dest any, query string, args ...any) error { }(time.Now()) } - return w.Tx.GetContext(ctx, dest, query, args...) + return w.dbw.checkErr(w.Tx.GetContext(ctx, dest, query, args...)) } func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error { @@ -284,13 +293,13 @@ func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error { return err } - return w.Get(dest, query, args...) + return w.dbw.checkErr(w.Get(dest, query, args...)) } func (w *sqlxTxWrapper) Exec(query string, args ...any) (sql.Result, error) { query = w.Tx.Rebind(query) - return w.ExecRaw(query, args...) + return w.dbw.checkErrWithResult(w.ExecRaw(query, args...)) } func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, error) { @@ -302,7 +311,7 @@ func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er }(time.Now()) } - return w.Tx.ExecContext(context.Background(), query, args...) + return w.dbw.checkErrWithResult(w.Tx.ExecContext(context.Background(), query, args...)) } func (w *sqlxTxWrapper) ExecBuilder(builder Builder) (sql.Result, error) { @@ -326,7 +335,7 @@ func (w *sqlxTxWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { }(time.Now()) } - return w.Tx.ExecContext(ctx, query, args...) + return w.dbw.checkErrWithResult(w.Tx.ExecContext(ctx, query, args...)) } func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { @@ -342,7 +351,7 @@ func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { }(time.Now()) } - return w.Tx.NamedExecContext(ctx, query, arg) + return w.dbw.checkErrWithResult(w.Tx.NamedExecContext(ctx, query, arg)) } func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { @@ -386,7 +395,7 @@ func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { } } - return res.rows, res.err + return res.rows, w.dbw.checkErr(res.err) } func (w *sqlxTxWrapper) QueryRowX(query string, args ...any) *sqlx.Row { @@ -414,7 +423,7 @@ func (w *sqlxTxWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) { }(time.Now()) } - return w.Tx.QueryxContext(ctx, query, args) + return w.dbw.checkErrWithRows(w.Tx.QueryxContext(ctx, query, args)) } func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error { @@ -428,7 +437,7 @@ func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error { }(time.Now()) } - return w.Tx.SelectContext(ctx, dest, query, args...) + return w.dbw.checkErr(w.Tx.SelectContext(ctx, dest, query, args...)) } func (w *sqlxTxWrapper) SelectBuilder(dest any, builder Builder) error { @@ -459,3 +468,23 @@ func printArgs(query string, dur time.Duration, args ...any) { } mlog.Debug(query, fields...) } + +func (w *sqlxDBWrapper) checkErrWithResult(res sql.Result, err error) (sql.Result, error) { + return res, w.checkErr(err) +} + +func (w *sqlxDBWrapper) checkErrWithRows(res *sqlx.Rows, err error) (*sqlx.Rows, error) { + return res, w.checkErr(err) +} + +func (w *sqlxDBWrapper) checkErr(err error) error { + var netError *net.OpError + if errors.As(err, &netError) && (!netError.Temporary() && !netError.Timeout()) { + w.isOnline.Store(false) + } + return err +} + +func (w *sqlxDBWrapper) Online() bool { + return w.isOnline.Load() +} diff --git a/server/channels/store/sqlstore/sqlx_wrapper_test.go b/server/channels/store/sqlstore/sqlx_wrapper_test.go index 07c6391767..c03d228935 100644 --- a/server/channels/store/sqlstore/sqlx_wrapper_test.go +++ b/server/channels/store/sqlstore/sqlx_wrapper_test.go @@ -6,6 +6,7 @@ package sqlstore import ( "context" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -28,12 +29,14 @@ func TestSqlX(t *testing.T) { } *settings.QueryTimeout = 1 store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: settings, + rrCounter: 0, + srCounter: 0, + settings: settings, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) defer store.Close() diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index d39f92661c..acd02b0853 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -49,7 +49,7 @@ const ( MySQLForeignKeyViolationErrorCode = 1452 PGDuplicateObjectErrorCode = "42710" MySQLDuplicateObjectErrorCode = 1022 - DBPingAttempts = 18 + DBPingAttempts = 5 DBPingTimeoutSecs = 10 // This is a numerical version string by postgres. The format is // 2 characters for major, minor, and patch version prior to 10. @@ -123,9 +123,9 @@ type SqlStore struct { masterX *sqlxDBWrapper - ReplicaXs []*sqlxDBWrapper + ReplicaXs []*atomic.Pointer[sqlxDBWrapper] - searchReplicaXs []*sqlxDBWrapper + searchReplicaXs []*atomic.Pointer[sqlxDBWrapper] replicaLagHandles []*dbsql.DB stores SqlStoreStores @@ -138,17 +138,28 @@ type SqlStore struct { isBinaryParam bool pgDefaultTextSearchConfig string + + quitMonitor chan struct{} + wgMonitor *sync.WaitGroup } func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlStore { store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: &settings, - metrics: metrics, + rrCounter: 0, + srCounter: 0, + settings: &settings, + metrics: metrics, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + err := store.initConnection() + if err != nil { + mlog.Fatal("Error setting up connections", mlog.Err(err)) + } + + store.wgMonitor.Add(1) + go store.monitorReplicas() ver, err := store.GetDbVersion(true) if err != nil { @@ -230,29 +241,28 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS // SetupConnection sets up the connection to the database and pings it to make sure it's alive. // It also applies any database configuration settings that are required. -func SetupConnection(connType string, dataSource string, settings *model.SqlSettings) *dbsql.DB { +func SetupConnection(connType string, dataSource string, settings *model.SqlSettings, attempts int) (*dbsql.DB, error) { db, err := dbsql.Open(*settings.DriverName, dataSource) if err != nil { - mlog.Fatal("Failed to open SQL connection to err.", mlog.Err(err)) + return nil, errors.Wrap(err, "failed to open SQL connection") } - for i := 0; i < DBPingAttempts; i++ { + for i := 0; i < attempts; i++ { // At this point, we have passed sql.Open, so we deliberately ignore any errors. sanitized, _ := SanitizeDataSource(*settings.DriverName, dataSource) mlog.Info("Pinging SQL", mlog.String("database", connType), mlog.String("dataSource", sanitized)) ctx, cancel := context.WithTimeout(context.Background(), DBPingTimeoutSecs*time.Second) defer cancel() err = db.PingContext(ctx) - if err == nil { - break - } else { - if i == DBPingAttempts-1 { - mlog.Fatal("Failed to ping DB, server will exit.", mlog.Err(err)) - } else { - mlog.Error("Failed to ping DB", mlog.Err(err), mlog.Int("retrying in seconds", DBPingTimeoutSecs)) - time.Sleep(DBPingTimeoutSecs * time.Second) + if err != nil { + if i == attempts-1 { + return nil, err } + mlog.Error("Failed to ping DB", mlog.Err(err), mlog.Int("retrying in seconds", DBPingTimeoutSecs)) + time.Sleep(DBPingTimeoutSecs * time.Second) + continue } + break } if strings.HasPrefix(connType, replicaLagPrefix) { @@ -272,7 +282,7 @@ func SetupConnection(connType string, dataSource string, settings *model.SqlSett db.SetConnMaxLifetime(time.Duration(*settings.ConnMaxLifetimeMilliseconds) * time.Millisecond) db.SetConnMaxIdleTime(time.Duration(*settings.ConnMaxIdleTimeMilliseconds) * time.Millisecond) - return db + return db, nil } func (ss *SqlStore) SetContext(context context.Context) { @@ -285,7 +295,7 @@ func (ss *SqlStore) Context() context.Context { func noOpMapper(s string) string { return s } -func (ss *SqlStore) initConnection() { +func (ss *SqlStore) initConnection() error { dataSource := *ss.settings.DataSource if ss.DriverName() == model.DatabaseDriverMysql { // TODO: We ignore the readTimeout datasource parameter for MySQL since QueryTimeout @@ -294,11 +304,14 @@ func (ss *SqlStore) initConnection() { var err error dataSource, err = ResetReadTimeout(dataSource) if err != nil { - mlog.Fatal("Failed to reset read timeout from datasource.", mlog.Err(err), mlog.String("src", dataSource)) + return errors.Wrap(err, "failed to reset read timeout from datasource") } } - handle := SetupConnection("master", dataSource, ss.settings) + handle, err := SetupConnection("master", dataSource, ss.settings, DBPingAttempts) + if err != nil { + return err + } ss.masterX = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), time.Duration(*ss.settings.QueryTimeout)*time.Second, *ss.settings.Trace) @@ -310,34 +323,32 @@ func (ss *SqlStore) initConnection() { } if len(ss.settings.DataSourceReplicas) > 0 { - ss.ReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceReplicas)) + ss.ReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceReplicas)) for i, replica := range ss.settings.DataSourceReplicas { - handle := SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings) - ss.ReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), - time.Duration(*ss.settings.QueryTimeout)*time.Second, - *ss.settings.Trace) - if ss.DriverName() == model.DatabaseDriverMysql { - ss.ReplicaXs[i].MapperFunc(noOpMapper) - } - if ss.metrics != nil { - ss.metrics.RegisterDBCollector(ss.ReplicaXs[i].DB.DB, "replica-"+strconv.Itoa(i)) + ss.ReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{} + handle, err = SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings, DBPingAttempts) + if err != nil { + // Initializing to be offline + ss.ReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}}) + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("replica-%v", i)), mlog.Err(err)) + continue } + ss.setDB(ss.ReplicaXs[i], handle, "replica-"+strconv.Itoa(i)) } } if len(ss.settings.DataSourceSearchReplicas) > 0 { - ss.searchReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceSearchReplicas)) + ss.searchReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceSearchReplicas)) for i, replica := range ss.settings.DataSourceSearchReplicas { - handle := SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings) - ss.searchReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), - time.Duration(*ss.settings.QueryTimeout)*time.Second, - *ss.settings.Trace) - if ss.DriverName() == model.DatabaseDriverMysql { - ss.searchReplicaXs[i].MapperFunc(noOpMapper) - } - if ss.metrics != nil { - ss.metrics.RegisterDBCollector(ss.searchReplicaXs[i].DB.DB, "searchreplica-"+strconv.Itoa(i)) + ss.searchReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{} + handle, err = SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings, DBPingAttempts) + if err != nil { + // Initializing to be offline + ss.searchReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}}) + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("search-replica-%v", i)), mlog.Err(err)) + continue } + ss.setDB(ss.searchReplicaXs[i], handle, "searchreplica-"+strconv.Itoa(i)) } } @@ -347,9 +358,14 @@ func (ss *SqlStore) initConnection() { if src.DataSource == nil { continue } - ss.replicaLagHandles[i] = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings) + ss.replicaLagHandles[i], err = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings, DBPingAttempts) + if err != nil { + mlog.Warn("Failed to setup replica lag handle. Skipping..", mlog.String("db", fmt.Sprintf(replicaLagPrefix+"-%d", i)), mlog.Err(err)) + continue + } } } + return nil } func (ss *SqlStore) DriverName() string { @@ -455,8 +471,15 @@ func (ss *SqlStore) GetSearchReplicaX() *sqlxDBWrapper { return ss.GetReplicaX() } - rrNum := atomic.AddInt64(&ss.srCounter, 1) % int64(len(ss.searchReplicaXs)) - return ss.searchReplicaXs[rrNum] + for i := 0; i < len(ss.searchReplicaXs); i++ { + rrNum := atomic.AddInt64(&ss.srCounter, 1) % int64(len(ss.searchReplicaXs)) + if ss.searchReplicaXs[rrNum].Load().Online() { + return ss.searchReplicaXs[rrNum].Load() + } + } + + // If all search replicas are down, then go with replica. + return ss.GetReplicaX() } func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper { @@ -464,23 +487,64 @@ func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper { return ss.GetMasterX() } - rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) - return ss.ReplicaXs[rrNum] -} - -func (ss *SqlStore) GetInternalReplicaDBs() []*sql.DB { - if len(ss.settings.DataSourceReplicas) == 0 || ss.lockedToMaster || !ss.hasLicense() { - return []*sql.DB{ - ss.GetMasterX().DB.DB, + for i := 0; i < len(ss.ReplicaXs); i++ { + rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) + if ss.ReplicaXs[rrNum].Load().Online() { + return ss.ReplicaXs[rrNum].Load() } } - dbs := make([]*sql.DB, len(ss.ReplicaXs)) - for i, rx := range ss.ReplicaXs { - dbs[i] = rx.DB.DB - } + // If all replicas are down, then go with master. + return ss.GetMasterX() +} - return dbs +func (ss *SqlStore) monitorReplicas() { + t := time.NewTicker(time.Duration(*ss.settings.ReplicaMonitorIntervalSeconds) * time.Second) + defer func() { + t.Stop() + ss.wgMonitor.Done() + }() + for { + select { + case <-ss.quitMonitor: + return + case <-t.C: + setupReplica := func(r *atomic.Pointer[sqlxDBWrapper], dsn, name string) { + if r.Load().Online() { + return + } + + handle, err := SetupConnection(name, dsn, ss.settings, 1) + if err != nil { + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", name), mlog.Err(err)) + return + } + if ss.metrics != nil && r.Load() != nil && r.Load().DB != nil { + ss.metrics.UnregisterDBCollector(r.Load().DB.DB, name) + } + ss.setDB(r, handle, name) + } + for i, replica := range ss.ReplicaXs { + setupReplica(replica, ss.settings.DataSourceReplicas[i], "replica-"+strconv.Itoa(i)) + } + + for i, replica := range ss.searchReplicaXs { + setupReplica(replica, ss.settings.DataSourceSearchReplicas[i], "search-replica-"+strconv.Itoa(i)) + } + } + } +} + +func (ss *SqlStore) setDB(replica *atomic.Pointer[sqlxDBWrapper], handle *dbsql.DB, name string) { + replica.Store(newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), + time.Duration(*ss.settings.QueryTimeout)*time.Second, + *ss.settings.Trace)) + if ss.DriverName() == model.DatabaseDriverMysql { + replica.Load().MapperFunc(noOpMapper) + } + if ss.metrics != nil { + ss.metrics.RegisterDBCollector(replica.Load().DB.DB, name) + } } func (ss *SqlStore) GetInternalReplicaDB() *sql.DB { @@ -489,7 +553,7 @@ func (ss *SqlStore) GetInternalReplicaDB() *sql.DB { } rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) - return ss.ReplicaXs[rrNum].DB.DB + return ss.ReplicaXs[rrNum].Load().DB.DB } func (ss *SqlStore) TotalMasterDbConnections() int { @@ -541,7 +605,10 @@ func (ss *SqlStore) TotalReadDbConnections() int { count := 0 for _, db := range ss.ReplicaXs { - count = count + db.Stats().OpenConnections + if !db.Load().Online() { + continue + } + count = count + db.Load().Stats().OpenConnections } return count @@ -554,7 +621,10 @@ func (ss *SqlStore) TotalSearchDbConnections() int { count := 0 for _, db := range ss.searchReplicaXs { - count = count + db.Stats().OpenConnections + if !db.Load().Online() { + continue + } + count = count + db.Load().Stats().OpenConnections } return count @@ -782,9 +852,14 @@ func IsUniqueConstraintError(err error, indexName []string) bool { } func (ss *SqlStore) GetAllConns() []*sqlxDBWrapper { - all := make([]*sqlxDBWrapper, len(ss.ReplicaXs)+1) - copy(all, ss.ReplicaXs) - all[len(ss.ReplicaXs)] = ss.masterX + all := make([]*sqlxDBWrapper, 0, len(ss.ReplicaXs)+1) + for i := range ss.ReplicaXs { + if !ss.ReplicaXs[i].Load().Online() { + continue + } + all = append(all, ss.ReplicaXs[i].Load()) + } + all = append(all, ss.masterX) return all } @@ -807,11 +882,24 @@ func (ss *SqlStore) RecycleDBConnections(d time.Duration) { func (ss *SqlStore) Close() { ss.masterX.Close() + // Closing monitor and waiting for it to be done. + // This needs to be done before closing the replica handles. + close(ss.quitMonitor) + ss.wgMonitor.Wait() + for _, replica := range ss.ReplicaXs { - replica.Close() + if replica.Load().Online() { + replica.Load().Close() + } } for _, replica := range ss.searchReplicaXs { + if replica.Load().Online() { + replica.Load().Close() + } + } + + for _, replica := range ss.replicaLagHandles { replica.Close() } } @@ -1132,7 +1220,10 @@ func (ss *SqlStore) migrate(direction migrationDirection) error { if err != nil { return err } - db := SetupConnection("master", dataSource, ss.settings) + db, err2 := SetupConnection("master", dataSource, ss.settings, DBPingAttempts) + if err2 != nil { + return err2 + } driver, err = ms.WithInstance(db) defer db.Close() case model.DatabaseDriverPostgres: diff --git a/server/channels/store/sqlstore/store_test.go b/server/channels/store/sqlstore/store_test.go index c218fa205d..699ee53e98 100644 --- a/server/channels/store/sqlstore/store_test.go +++ b/server/channels/store/sqlstore/store_test.go @@ -761,13 +761,15 @@ func TestReplicaLagQuery(t *testing.T) { mockMetrics.On("RegisterDBCollector", mock.AnythingOfType("*sql.DB"), "master") store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: settings, - metrics: mockMetrics, + rrCounter: 0, + srCounter: 0, + settings: settings, + metrics: mockMetrics, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) store.stores.post = newSqlPostStore(store, mockMetrics) err = store.migrate(migrationsDirectionUp) require.NoError(t, err) @@ -839,9 +841,11 @@ func TestMySQLReadTimeout(t *testing.T) { settings.DataSource = &dataSource store := &SqlStore{ - settings: settings, + settings: settings, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) defer store.Close() _, err = store.GetMasterX().ExecNoTimeout(`SELECT SLEEP(3)`) diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 7da24fd24c..20af689736 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -72,10 +72,7 @@ type Store interface { // GetInternalMasterDB allows access to the raw master DB // handle for the multi-product architecture. GetInternalMasterDB() *sql.DB - // GetInternalReplicaDBs allows access to the raw replica DB - // handles for the multi-product architecture. GetInternalReplicaDB() *sql.DB - GetInternalReplicaDBs() []*sql.DB TotalMasterDbConnections() int TotalReadDbConnections() int TotalSearchDbConnections() int diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index bb06fb9005..bca15c95e0 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -346,22 +346,6 @@ func (_m *Store) GetInternalReplicaDB() *sql.DB { return r0 } -// GetInternalReplicaDBs provides a mock function with given fields: -func (_m *Store) GetInternalReplicaDBs() []*sql.DB { - ret := _m.Called() - - var r0 []*sql.DB - if rf, ok := ret.Get(0).(func() []*sql.DB); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*sql.DB) - } - } - - return r0 -} - // Group provides a mock function with given fields: func (_m *Store) Group() store.GroupStore { ret := _m.Called() diff --git a/server/channels/store/storetest/settings.go b/server/channels/store/storetest/settings.go index a1253f28bb..0104b950bb 100644 --- a/server/channels/store/storetest/settings.go +++ b/server/channels/store/storetest/settings.go @@ -261,6 +261,7 @@ func MakeSqlSettings(driver string, withReplica bool) *model.SqlSettings { } log("Created temporary " + driver + " database " + dbName) + settings.ReplicaMonitorIntervalSeconds = model.NewInt(5) return settings } diff --git a/server/channels/testlib/helper.go b/server/channels/testlib/helper.go index f74a562568..f6a1b22531 100644 --- a/server/channels/testlib/helper.go +++ b/server/channels/testlib/helper.go @@ -331,7 +331,7 @@ func (h *MainHelper) SetReplicationLagForTesting(seconds int) error { func (h *MainHelper) execOnEachReplica(query string, args ...any) error { for _, replica := range h.SQLStore.ReplicaXs { - _, err := replica.Exec(query, args...) + _, err := replica.Load().Exec(query, args...) if err != nil { return err } diff --git a/server/model/config.go b/server/model/config.go index f278c97cdf..af4341bdfa 100644 --- a/server/model/config.go +++ b/server/model/config.go @@ -1173,6 +1173,7 @@ type SqlSettings struct { DisableDatabaseSearch *bool `access:"environment_database,write_restrictable,cloud_restrictable"` MigrationsStatementTimeoutSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"` ReplicaLagSettings []*ReplicaLagSettings `access:"environment_database,write_restrictable,cloud_restrictable"` // telemetry: none + ReplicaMonitorIntervalSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"` } func (s *SqlSettings) SetDefaults(isUpdate bool) { @@ -1237,6 +1238,10 @@ func (s *SqlSettings) SetDefaults(isUpdate bool) { if s.ReplicaLagSettings == nil { s.ReplicaLagSettings = []*ReplicaLagSettings{} } + + if s.ReplicaMonitorIntervalSeconds == nil { + s.ReplicaMonitorIntervalSeconds = NewInt(5) + } } type LogSettings struct { diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index d4da4770bc..4fdbdf51ec 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -522,6 +522,7 @@ func (ts *TelemetryService) trackConfig() { "query_timeout": *cfg.SqlSettings.QueryTimeout, "disable_database_search": *cfg.SqlSettings.DisableDatabaseSearch, "migrations_statement_timeout_seconds": *cfg.SqlSettings.MigrationsStatementTimeoutSeconds, + "replica_monitor_interval_seconds": *cfg.SqlSettings.ReplicaMonitorIntervalSeconds, }) ts.SendTelemetry(TrackConfigLog, map[string]any{ From a24111f9bd364dccb7a33113b5098aaa49305760 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Wed, 19 Apr 2023 15:20:34 +0300 Subject: [PATCH 072/113] MM-45009: Delete ThreadMemberships from "left" channels (#22559) * MM-50550: Filter out threads from "left" channels v2 Currently leaving a channel doesn't affect the thread memberships of that user/channel combination. This PR aims to filter out all threads from those channels for the user. Adds a DeleteAt column in the ThreadMemberships table, and filter out all thread memberships that are "deleted". Each time a user leaves a channel all thread memberships are going to be marked as deleted, and when a user joins a channel again all those existing thread memberships will be re-instantiated. Adds a migration to mark all existing thread memberships as deleted depending on whether there exists a channel membership for that channel/user. * Added migration files into list * Fixes tests * Fixes case where DeleteAt would be null * Guard thread API endpoints with appropriate perms * Deletes ThreadMembership rows upon leaving channel * Minor style changes * Use NoTranslation error * Refactors tests * Adds API tests to assert permissions on Team * Adds tests, and fixes migrations * Fixes test description * Fix test * Removes check on DM/GMs * Change the MySQL query in the migration --------- Co-authored-by: Mattermost Build --- server/channels/api4/user.go | 20 +++ server/channels/api4/user_test.go | 110 ++++++++++------ server/channels/app/channel.go | 3 + server/channels/app/channel_test.go | 79 ++++++++++++ server/channels/db/migrations/migrations.list | 4 + .../000107_threadmemberships_cleanup.down.sql | 1 + .../000107_threadmemberships_cleanup.up.sql | 5 + .../000107_threadmemberships_cleanup.down.sql | 1 + .../000107_threadmemberships_cleanup.up.sql | 12 ++ .../opentracinglayer/opentracinglayer.go | 18 +++ .../channels/store/retrylayer/retrylayer.go | 21 ++++ .../channels/store/sqlstore/thread_store.go | 40 +++++- server/channels/store/store.go | 1 + .../store/storetest/mocks/ThreadStore.go | 14 +++ .../channels/store/storetest/thread_store.go | 119 ++++++++++++++++++ .../channels/store/timerlayer/timerlayer.go | 16 +++ 16 files changed, 426 insertions(+), 38 deletions(-) create mode 100644 server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql create mode 100644 server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql create mode 100644 server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql create mode 100644 server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index c259922e82..2269efe88c 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -3106,6 +3106,10 @@ func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } extendedStr := r.URL.Query().Get("extended") extended, _ := strconv.ParseBool(extendedStr) @@ -3136,6 +3140,10 @@ func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } options := model.GetUserThreadsOpts{ Since: 0, @@ -3213,6 +3221,10 @@ func updateReadStateThreadByUser(c *Context, w http.ResponseWriter, r *http.Requ c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } thread, err := c.App.UpdateThreadReadForUser(c.AppContext, c.AppContext.Session().Id, c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, c.Params.Timestamp) if err != nil { @@ -3279,6 +3291,10 @@ func unfollowThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } err := c.App.UpdateThreadFollowForUser(c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, false) if err != nil { @@ -3338,6 +3354,10 @@ func updateReadStateAllThreadsByUser(c *Context, w http.ResponseWriter, r *http. c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } err := c.App.UpdateThreadsReadForUser(c.Params.UserId, c.Params.TeamId) if err != nil { diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 8d9673ed9e..0f9ce87d33 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -6360,6 +6360,15 @@ func TestGetThreadsForUser(t *testing.T) { require.NoError(t, err) require.Equal(t, uss.TotalUnreadThreads, int64(2)) }) + + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) + + _, resp, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) } func TestThreadSocketEvents(t *testing.T) { @@ -6855,52 +6864,64 @@ func TestSingleThreadGet(t *testing.T) { }) client := th.Client - defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) - defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.SystemAdminUser.Id) - // create a post by regular user - rpost, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) - // reply with another - postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + t.Run("get single thread", func(t *testing.T) { + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.SystemAdminUser.Id) - // create another thread to check that we are not returning it by mistake - rpost2, _ := postAndCheck(t, client, &model.Post{ - ChannelId: th.BasicChannel2.Id, - Message: "testMsg2", - Metadata: &model.PostMetadata{ - Priority: &model.PostPriority{ - Priority: model.NewString(model.PostPriorityUrgent), + // create a post by regular user + rpost, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) + // reply with another + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + + // create another thread to check that we are not returning it by mistake + rpost2, _ := postAndCheck(t, client, &model.Post{ + ChannelId: th.BasicChannel2.Id, + Message: "testMsg2", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, }, - }, - }) - postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) + }) + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) - // regular user should have two threads with 3 replies total - threads, _ := checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 2, 2, nil) + // regular user should have two threads with 3 replies total + threads, _ := checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 2, 2, nil) - tr, _, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, false) - require.NoError(t, err) - require.NotNil(t, tr) - require.Equal(t, threads.Threads[0].PostId, tr.PostId) - require.Empty(t, tr.Participants[0].Username) + tr, _, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, false) + require.NoError(t, err) + require.NotNil(t, tr) + require.Equal(t, threads.Threads[0].PostId, tr.PostId) + require.Empty(t, tr.Participants[0].Username) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.PostPriority = false + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.NotEmpty(t, tr.Participants[0].Username) + require.Equal(t, false, tr.IsUrgent) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.Equal(t, true, tr.IsUrgent) }) - tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) - require.NoError(t, err) - require.NotEmpty(t, tr.Participants[0].Username) - require.Equal(t, false, tr.IsUrgent) + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.PostPriority = true - cfg.FeatureFlags.PostPriority = true + _, resp, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), false) + require.Error(t, err) + CheckForbiddenStatus(t, resp) }) - - tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) - require.NoError(t, err) - require.Equal(t, true, tr.IsUrgent) } func TestMaintainUnreadMentionsInThread(t *testing.T) { @@ -7072,6 +7093,23 @@ func TestReadThreads(t *testing.T) { checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 1, 1, nil) }) + + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) + + _, resp, err := th.Client.UpdateThreadReadForUser(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.GetMillis()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, resp, err = th.Client.SetThreadUnreadByPostId(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.NewId()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = th.Client.UpdateThreadsReadForUser(th.BasicUser.Id, th.BasicTeam.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) } func TestMarkThreadUnreadMentionCount(t *testing.T) { diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 09fb2ce1cc..162254b84b 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -2518,6 +2518,9 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove if err := a.Srv().Store().ChannelMemberHistory().LogLeaveEvent(userIDToRemove, channel.Id, model.GetMillis()); err != nil { return model.NewAppError("removeUserFromChannel", "app.channel_member_history.log_leave_event.internal_error", nil, "", http.StatusInternalServerError).Wrap(err) } + if err := a.Srv().Store().Thread().DeleteMembershipsForChannel(userIDToRemove, channel.Id); err != nil { + return model.NewAppError("removeUserFromChannel", model.NoTranslation, nil, "failed to delete threadmemberships upon leaving channel", http.StatusInternalServerError).Wrap(err) + } if isGuest { currentMembers, err := a.GetChannelMembersForUser(c, channel.TeamId, userIDToRemove) diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index eaf1171a6c..2b427ba806 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -609,6 +609,85 @@ func TestLeaveDefaultChannel(t *testing.T) { _, err = th.App.GetChannelMember(th.Context, townSquare.Id, guest.Id) assert.NotNil(t, err) }) + + t.Run("Trying to leave the default channel should not delete thread memberships", func(t *testing.T) { + post := &model.Post{ + ChannelId: townSquare.Id, + Message: "root post", + UserId: th.BasicUser.Id, + } + rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true) + require.Nil(t, err) + + reply := &model.Post{ + ChannelId: townSquare.Id, + Message: "reply post", + UserId: th.BasicUser.Id, + RootId: rpost.Id, + } + _, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true) + require.Nil(t, err) + + threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + + err = th.App.LeaveChannel(th.Context, townSquare.Id, th.BasicUser.Id) + assert.NotNil(t, err, "It should fail to remove a regular user from the default channel") + assert.Equal(t, err.Id, "api.channel.remove.default.app_error") + + threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + }) +} + +func TestLeaveChannel(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + createThread := func(channel *model.Channel) (rpost *model.Post) { + t.Helper() + post := &model.Post{ + ChannelId: channel.Id, + Message: "root post", + UserId: th.BasicUser.Id, + } + + rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true) + require.Nil(t, err) + + reply := &model.Post{ + ChannelId: channel.Id, + Message: "reply post", + UserId: th.BasicUser.Id, + RootId: rpost.Id, + } + _, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true) + require.Nil(t, err) + + return rpost + } + + t.Run("thread memberships are deleted", func(t *testing.T) { + createThread(th.BasicChannel) + channel2 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen) + createThread(channel2) + + threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 2) + + err = th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id) + require.Nil(t, err) + + _, err = th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) + require.NotNil(t, err, "It should remove channel membership") + + threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + }) } func TestLeaveLastChannel(t *testing.T) { diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 6a7d33d5c6..47f5bf333b 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -212,6 +212,8 @@ channels/db/migrations/mysql/000105_remove_tokens.down.sql channels/db/migrations/mysql/000105_remove_tokens.up.sql channels/db/migrations/mysql/000106_fileinfo_channelid.down.sql channels/db/migrations/mysql/000106_fileinfo_channelid.up.sql +channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql +channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -424,3 +426,5 @@ channels/db/migrations/postgres/000105_remove_tokens.down.sql channels/db/migrations/postgres/000105_remove_tokens.up.sql channels/db/migrations/postgres/000106_fileinfo_channelid.down.sql channels/db/migrations/postgres/000106_fileinfo_channelid.up.sql +channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql +channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql diff --git a/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql new file mode 100644 index 0000000000..4743bd6462 --- /dev/null +++ b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql @@ -0,0 +1 @@ +-- Skipping it because the forward migrations are destructive diff --git a/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql new file mode 100644 index 0000000000..90644be3f3 --- /dev/null +++ b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql @@ -0,0 +1,5 @@ +DELETE FROM + tm USING ThreadMemberships AS tm + JOIN Threads ON Threads.PostId = tm.PostId +WHERE + (tm.UserId, Threads.ChannelId) NOT IN (SELECT UserId, ChannelId FROM ChannelMembers); diff --git a/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql new file mode 100644 index 0000000000..4743bd6462 --- /dev/null +++ b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql @@ -0,0 +1 @@ +-- Skipping it because the forward migrations are destructive diff --git a/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql new file mode 100644 index 0000000000..0ec82905bc --- /dev/null +++ b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql @@ -0,0 +1,12 @@ +DELETE FROM threadmemberships WHERE (postid, userid) IN ( + SELECT + threadmemberships.postid, + threadmemberships.userid + FROM + threadmemberships + JOIN threads ON threads.postid = threadmemberships.postid + LEFT JOIN channelmembers ON channelmembers.userid = threadmemberships.userid + AND threads.channelid = channelmembers.channelid + WHERE + channelmembers.channelid IS NULL +); diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 941704a2f4..66a5051260 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -10123,6 +10123,24 @@ func (s *OpenTracingLayerThreadStore) DeleteMembershipForUser(userId string, pos return err } +func (s *OpenTracingLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteMembershipsForChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteOrphanedRows") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 91a3209c44..b39c79ab9b 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -11563,6 +11563,27 @@ func (s *RetryLayerThreadStore) DeleteMembershipForUser(userId string, postID st } +func (s *RetryLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + + tries := 0 + for { + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { tries := 0 diff --git a/server/channels/store/sqlstore/thread_store.go b/server/channels/store/sqlstore/thread_store.go index b731b0b71c..66ce1f42a1 100644 --- a/server/channels/store/sqlstore/thread_store.go +++ b/server/channels/store/sqlstore/thread_store.go @@ -688,6 +688,28 @@ func (s *SqlThreadStore) UpdateMembership(membership *model.ThreadMembership) (* return s.updateMembership(s.GetMasterX(), membership) } +func (s *SqlThreadStore) DeleteMembershipsForChannel(userID, channelID string) error { + subQuery := s.getSubQueryBuilder(). + Select("1"). + From("Threads"). + Where(sq.And{ + sq.Expr("Threads.PostId = ThreadMemberships.PostId"), + sq.Eq{"Threads.ChannelId": channelID}, + }) + + query := s.getQueryBuilder(). + Delete("ThreadMemberships"). + Where(sq.Eq{"UserId": userID}). + Where(sq.Expr("EXISTS (?)", subQuery)) + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return errors.Wrapf(err, "failed to remove thread memberships with userid=%s channelid=%s", userID, channelID) + } + + return nil +} + func (s *SqlThreadStore) updateMembership(ex sqlxExecutor, membership *model.ThreadMembership) (*model.ThreadMembership, error) { query := s.getQueryBuilder(). Update("ThreadMemberships"). @@ -712,7 +734,14 @@ func (s *SqlThreadStore) GetMembershipsForUser(userId, teamId string) ([]*model. memberships := []*model.ThreadMembership{} query := s.getQueryBuilder(). - Select("ThreadMemberships.*"). + Select( + "ThreadMemberships.PostId", + "ThreadMemberships.UserId", + "ThreadMemberships.Following", + "ThreadMemberships.LastUpdated", + "ThreadMemberships.LastViewed", + "ThreadMemberships.UnreadMentions", + ). Join("Threads ON Threads.PostId = ThreadMemberships.PostId"). From("ThreadMemberships"). Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}). @@ -732,7 +761,14 @@ func (s *SqlThreadStore) GetMembershipForUser(userId, postId string) (*model.Thr func (s *SqlThreadStore) getMembershipForUser(ex sqlxExecutor, userId, postId string) (*model.ThreadMembership, error) { var membership model.ThreadMembership query := s.getQueryBuilder(). - Select("*"). + Select( + "PostId", + "UserId", + "Following", + "LastUpdated", + "LastViewed", + "UnreadMentions", + ). From("ThreadMemberships"). Where(sq.And{ sq.Eq{"PostId": postId}, diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 20af689736..cd813239d4 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -344,6 +344,7 @@ type ThreadStore interface { PermanentDeleteBatchThreadMembershipsForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) DeleteOrphanedRows(limit int) (deleted int64, err error) GetThreadUnreadReplyCount(threadMembership *model.ThreadMembership) (int64, error) + DeleteMembershipsForChannel(userID, channelID string) error // Insights - threads GetTopThreadsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopThreadList, error) diff --git a/server/channels/store/storetest/mocks/ThreadStore.go b/server/channels/store/storetest/mocks/ThreadStore.go index 60b9211db2..661194a935 100644 --- a/server/channels/store/storetest/mocks/ThreadStore.go +++ b/server/channels/store/storetest/mocks/ThreadStore.go @@ -29,6 +29,20 @@ func (_m *ThreadStore) DeleteMembershipForUser(userId string, postID string) err return r0 } +// DeleteMembershipsForChannel provides a mock function with given fields: userID, channelID +func (_m *ThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + ret := _m.Called(userID, channelID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(userID, channelID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteOrphanedRows provides a mock function with given fields: limit func (_m *ThreadStore) DeleteOrphanedRows(limit int) (int64, error) { ret := _m.Called(limit) diff --git a/server/channels/store/storetest/thread_store.go b/server/channels/store/storetest/thread_store.go index 4cd64c8f1e..efbc74d3ac 100644 --- a/server/channels/store/storetest/thread_store.go +++ b/server/channels/store/storetest/thread_store.go @@ -29,6 +29,7 @@ func TestThreadStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("MarkAllAsReadByChannels", func(t *testing.T) { testMarkAllAsReadByChannels(t, ss) }) t.Run("GetTopThreads", func(t *testing.T) { testGetTopThreads(t, ss) }) t.Run("MarkAllAsReadByTeam", func(t *testing.T) { testMarkAllAsReadByTeam(t, ss) }) + t.Run("DeleteMembershipsForChannel", func(t *testing.T) { testDeleteMembershipsForChannel(t, ss) }) } func testThreadStorePopulation(t *testing.T, ss store.Store) { @@ -1914,3 +1915,121 @@ func testMarkAllAsReadByTeam(t *testing.T, ss store.Store) { assertThreadReplyCount(t, userBID, team2.Id, 1, "expected 1 unread message in team2 for userB") }) } + +func testDeleteMembershipsForChannel(t *testing.T, ss store.Store) { + createThreadMembership := func(userID, postID string) (*model.ThreadMembership, func()) { + t.Helper() + opts := store.ThreadMembershipOpts{ + Following: true, + IncrementMentions: false, + UpdateFollowing: true, + UpdateViewedTimestamp: false, + UpdateParticipants: false, + } + mem, err := ss.Thread().MaintainMembership(userID, postID, opts) + require.NoError(t, err) + + return mem, func() { + err := ss.Thread().DeleteMembershipForUser(userID, postID) + require.NoError(t, err) + } + } + + postingUserID := model.NewId() + userAID := model.NewId() + userBID := model.NewId() + + team, err := ss.Team().Save(&model.Team{ + DisplayName: "DisplayName", + Name: "team" + model.NewId(), + Email: MakeEmail(), + Type: model.TeamOpen, + }) + require.NoError(t, err) + + channel1, err := ss.Channel().Save(&model.Channel{ + TeamId: team.Id, + DisplayName: "DisplayName", + Name: "channel1" + model.NewId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + channel2, err := ss.Channel().Save(&model.Channel{ + TeamId: team.Id, + DisplayName: "DisplayName2", + Name: "channel2" + model.NewId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + + rootPost1, err := ss.Post().Save(&model.Post{ + ChannelId: channel1.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + ChannelId: channel1.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + RootId: rootPost1.Id, + }) + require.NoError(t, err) + + rootPost2, err := ss.Post().Save(&model.Post{ + ChannelId: channel2.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + }) + require.NoError(t, err) + _, err = ss.Post().Save(&model.Post{ + ChannelId: channel2.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + RootId: rootPost2.Id, + }) + require.NoError(t, err) + + t.Run("should return memberships for user", func(t *testing.T) { + memA1, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + + membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsA, 2) + require.ElementsMatch(t, []*model.ThreadMembership{memA1, memA2}, membershipsA) + }) + + t.Run("should delete memberships for user for channel", func(t *testing.T) { + _, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + + ss.Thread().DeleteMembershipsForChannel(userAID, channel1.Id) + membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsA, 1) + require.ElementsMatch(t, []*model.ThreadMembership{memA2}, membershipsA) + }) + + t.Run("deleting memberships for channel for userA should not affect userB", func(t *testing.T) { + _, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + _, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + memB1, cleanupB2 := createThreadMembership(userBID, rootPost1.Id) + defer cleanupB2() + + membershipsB, err := ss.Thread().GetMembershipsForUser(userBID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsB, 1) + require.ElementsMatch(t, []*model.ThreadMembership{memB1}, membershipsB) + }) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index b52293e013..3dc9a94c19 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -9112,6 +9112,22 @@ func (s *TimerLayerThreadStore) DeleteMembershipForUser(userId string, postID st return err } +func (s *TimerLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + start := time.Now() + + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.DeleteMembershipsForChannel", success, elapsed) + } + return err +} + func (s *TimerLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { start := time.Now() From 3ca789979aed94e9fbb4c5668785e9b86a12f71f Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 19 Apr 2023 09:23:45 -0400 Subject: [PATCH 073/113] remove other use of relfection to determine error. --- server/channels/api4/hosted_customer.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index 5affb6c1b1..c7beef7ebd 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "reflect" "time" "github.com/pkg/errors" @@ -374,10 +373,9 @@ func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) return } - license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) - + license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) // dealing with an AppError - if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { + if appErr != nil { if confirmResponse != nil { c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) } From b5c48e75b385533cd3dc329b658cb47768a7b992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Andr=C3=A9s=20V=C3=A9lez=20Vidal?= Date: Wed, 19 Apr 2023 15:31:47 +0200 Subject: [PATCH 074/113] MM 50960 - Add company name and invite members pages to preparing-workspace for self-hosted (#22838) * MM-50960 - store system organization name * restore the preparing workspace plugins and invite screens * add back the page lines for the design * add lines back and organize styles * set back documentation to monorepo style and disable board as a product * fix organization link and style skip button * create team on organization name screen continue button click * make sure there are not already created team and if so just update team name * update the team display name if team has already been created * cover error scenarios during team creation * add pr feedback and add a couple of unit tests * fix translation server error; make sure only update display name if it has changed in the form * temp advances * rewrite unit tests using react-testing library; fix unit tests * fix translations * make sure the launching workspace finish in cloud installations * remove redundant validation * fix unit tests * remove unintended config value left after merge conflict --- .../support/server/default_config.ts | 1 - server/channels/api4/system_test.go | 1 + server/channels/app/onboarding.go | 18 + server/channels/app/onboarding_test.go | 30 + server/i18n/en.json | 4 + server/model/onboarding.go | 1 + server/model/system.go | 1 + .../channels/src/actions/global_actions.tsx | 10 +- .../do_verify_email/do_verify_email.tsx | 18 +- .../user_guide_dropdown/index.ts | 2 - .../user_guide_dropdown.test.tsx | 1 - .../channels/src/components/login/login.tsx | 7 +- .../invite_members.test.tsx.snap | 80 ++ .../invite_members_link.test.tsx.snap | 29 + .../organization_status.test.tsx.snap | 7 + .../components/preparing_workspace/index.tsx | 3 +- .../preparing_workspace/invite_members.scss | 51 ++ .../invite_members.test.tsx | 71 ++ .../preparing_workspace/invite_members.tsx | 114 +++ .../invite_members_illustration.tsx | 838 ++++++++++++++++++ .../invite_members_link.scss | 51 ++ .../invite_members_link.test.tsx | 61 ++ .../invite_members_link.tsx | 64 ++ .../preparing_workspace/mixins.scss | 12 + .../preparing_workspace/organization.scss | 63 ++ .../preparing_workspace/organization.tsx | 206 +++++ .../organization_status.test.tsx | 46 + .../organization_status.tsx | 83 ++ .../preparing_workspace/page_line.scss | 10 + .../preparing_workspace/page_line.tsx | 35 + .../preparing_workspace/plugins.scss | 3 + .../preparing_workspace/plugins.tsx | 82 +- .../preparing_workspace.scss | 52 ++ .../preparing_workspace.tsx | 188 +++- .../single_column_layout.scss | 1 - .../components/preparing_workspace/steps.ts | 27 +- webapp/channels/src/components/root/root.tsx | 8 +- .../components/root/root_redirect/index.ts | 7 +- .../src/components/signup/signup.test.tsx | 11 - .../channels/src/components/signup/signup.tsx | 18 +- .../src/components/terms_of_service/index.ts | 3 - .../terms_of_service.test.tsx | 1 - .../terms_of_service/terms_of_service.tsx | 5 +- webapp/channels/src/i18n/en.json | 25 +- .../src/selectors/entities/preferences.ts | 4 - webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/types/src/setup.ts | 1 + 47 files changed, 2248 insertions(+), 107 deletions(-) create mode 100644 server/channels/app/onboarding_test.go create mode 100644 webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap create mode 100644 webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap create mode 100644 webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members.scss create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members.test.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members_link.scss create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/invite_members_link.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/mixins.scss create mode 100644 webapp/channels/src/components/preparing_workspace/organization.scss create mode 100644 webapp/channels/src/components/preparing_workspace/organization.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/organization_status.test.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/organization_status.tsx create mode 100644 webapp/channels/src/components/preparing_workspace/page_line.scss create mode 100644 webapp/channels/src/components/preparing_workspace/page_line.tsx diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 2d8cb12c58..3e8e5ed881 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -665,7 +665,6 @@ const defaultServerConfig: AdminConfig = { BoardsFeatureFlags: '', BoardsDataRetention: false, NormalizeLdapDNs: false, - UseCaseOnboarding: true, GraphQL: false, InsightsEnabled: true, CommandPalette: false, diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index 5921e32802..25574e4400 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -892,6 +892,7 @@ func TestCompleteOnboarding(t *testing.T) { req := &model.CompleteOnboardingRequest{ InstallPlugins: []string{"testplugin2"}, + Organization: "my-org", } t.Run("as a regular user", func(t *testing.T) { diff --git a/server/channels/app/onboarding.go b/server/channels/app/onboarding.go index 2dd85749d9..3b76aefe53 100644 --- a/server/channels/app/onboarding.go +++ b/server/channels/app/onboarding.go @@ -28,6 +28,24 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError { } func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { + isCloud := a.Srv().License() != nil && *a.Srv().License().Features.Cloud + + if !isCloud && request.Organization == "" { + mlog.Error("No organization name provided for self hosted onboarding") + return model.NewAppError("CompleteOnboarding", "api.error_no_organization_name_provided_for_self_hosted_onboarding", nil, "", http.StatusBadRequest) + } + + if request.Organization != "" { + err := a.Srv().Store().System().SaveOrUpdate(&model.System{ + Name: model.SystemOrganizationName, + Value: request.Organization, + }) + if err != nil { + // don't block onboarding because of that. + a.Log().Error("failed to save organization name", mlog.Err(err)) + } + } + pluginsEnvironment := a.Channels().GetPluginsEnvironment() if pluginsEnvironment == nil { return a.markAdminOnboardingComplete(c) diff --git a/server/channels/app/onboarding_test.go b/server/channels/app/onboarding_test.go new file mode 100644 index 0000000000..cf8462cf28 --- /dev/null +++ b/server/channels/app/onboarding_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/server/v8/channels/app/request" + mm_model "github.com/mattermost/mattermost-server/server/v8/model" +) + +func TestOnboardingSavesOrganizationName(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + err := th.App.CompleteOnboarding(&request.Context{}, &mm_model.CompleteOnboardingRequest{ + Organization: "Mattermost In Tests", + }) + require.Nil(t, err) + defer func() { + th.App.Srv().Store().System().PermanentDeleteByName(mm_model.SystemOrganizationName) + }() + + sys, storeErr := th.App.Srv().Store().System().GetByName(mm_model.SystemOrganizationName) + require.NoError(t, storeErr) + require.Equal(t, "Mattermost In Tests", sys.Value) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 598462a448..40f626291f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1777,6 +1777,10 @@ "id": "api.error_get_first_admin_visit_marketplace_status", "translation": "Error trying to retrieve the first admin visit marketplace status from the store." }, + { + "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", + "translation": "Error no organization name provided for self hosted onboarding." + }, { "id": "api.error_set_first_admin_complete_setup", "translation": "Error trying to save first admin complete setup in the store." diff --git a/server/model/onboarding.go b/server/model/onboarding.go index 797bea7c1d..0fe5e91ffa 100644 --- a/server/model/onboarding.go +++ b/server/model/onboarding.go @@ -10,6 +10,7 @@ import ( // CompleteOnboardingRequest describes parameters of the requested plugin. type CompleteOnboardingRequest struct { + Organization string `json:"organization"` // Organization is the name of the organization InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed } diff --git a/server/model/system.go b/server/model/system.go index fbc2aaa684..24b4fce9c9 100644 --- a/server/model/system.go +++ b/server/model/system.go @@ -16,6 +16,7 @@ const ( SystemAsymmetricSigningKeyKey = "AsymmetricSigningKey" SystemPostActionCookieSecretKey = "PostActionCookieSecret" SystemInstallationDateKey = "InstallationDate" + SystemOrganizationName = "OrganizationName" SystemFirstServerRunTimestampKey = "FirstServerRunTimestamp" SystemClusterEncryptionKey = "ClusterEncryptionKey" SystemUpgradedFromTeId = "UpgradedFromTE" diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index cb22542fa4..6ac09280ab 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -14,7 +14,7 @@ import {Preferences} from 'mattermost-redux/constants'; import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {getBool, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ChannelTypes} from 'mattermost-redux/action_types'; @@ -367,11 +367,19 @@ export async function redirectUserToDefaultTeam() { return; } + // if the user is the first admin + const isUserFirstAdmin = isFirstAdmin(state); + const locale = getCurrentLocale(state); const teamId = LocalStorageStore.getPreviousTeamId(user.id); let myTeams = getMyTeams(state); if (myTeams.length === 0) { + if (isUserFirstAdmin) { + getHistory().push('/preparing-workspace'); + return; + } + getHistory().push('/select_team'); return; } diff --git a/webapp/channels/src/components/do_verify_email/do_verify_email.tsx b/webapp/channels/src/components/do_verify_email/do_verify_email.tsx index 7b03d9e7d5..0e81ead2c7 100644 --- a/webapp/channels/src/components/do_verify_email/do_verify_email.tsx +++ b/webapp/channels/src/components/do_verify_email/do_verify_email.tsx @@ -6,7 +6,6 @@ import {useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {useLocation, useHistory} from 'react-router-dom'; -import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg'; @@ -15,7 +14,6 @@ import LoadingScreen from 'components/loading_screen'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; import {verifyUserEmail, getMe} from 'mattermost-redux/actions/users'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {DispatchFunc} from 'mattermost-redux/types/actions'; @@ -40,7 +38,6 @@ const DoVerifyEmail = () => { const token = params.get('token') ?? ''; const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.PENDING); const [serverError, setServerError] = useState(''); @@ -52,16 +49,11 @@ const DoVerifyEmail = () => { const handleRedirect = () => { if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first time onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - return; - } - - redirectUserToDefaultTeam(); + // need info about whether admin or not, + // and whether admin has already completed + // first time onboarding. Instead of fetching and orchestrating that here, + // let the default root component handle it. + history.push('/'); return; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts index a59ff532cc..5a2ac01c35 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts @@ -8,7 +8,6 @@ import {withRouter} from 'react-router-dom'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {GenericAction} from 'mattermost-redux/types/actions'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins'; @@ -32,7 +31,6 @@ function mapStateToProps(state: GlobalState) { teamUrl: getCurrentRelativeTeamUrl(state), pluginMenuItems: getUserGuideDropdownPluginMenuItems(state), isFirstAdmin: isFirstAdmin(state), - useCaseOnboarding: getUseCaseOnboarding(state), }; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx index effe92c1ad..aa1ac2e833 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx @@ -34,7 +34,6 @@ describe('components/channel_header/components/UserGuideDropdown', () => { }, pluginMenuItems: [], isFirstAdmin: false, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index bed62d7eed..c0e154e561 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -13,7 +13,7 @@ import {UserProfile} from '@mattermost/types/users'; import {Client4} from 'mattermost-redux/client'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getTeamByName, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {isSystemAdmin} from 'mattermost-redux/utils/user_utils'; @@ -104,7 +104,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const currentUser = useSelector(getCurrentUser); const experimentalPrimaryTeam = useSelector((state: GlobalState) => (ExperimentalPrimaryTeam ? getTeamByName(state, ExperimentalPrimaryTeam) : undefined)); const experimentalPrimaryTeamMember = useSelector((state: GlobalState) => getMyTeamMember(state, experimentalPrimaryTeam?.id ?? '')); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const isCloud = useSelector(isCurrentLicenseCloud); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -631,14 +630,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => { } else if (experimentalPrimaryTeamMember.team_id) { // Only set experimental team if user is on that team history.push(`/${ExperimentalPrimaryTeam}`); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first time onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap new file mode 100644 index 0000000000..4aa2442f0a --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InviteMembers component should match snapshot 1`] = ` +
+
+
+
+
+
+ Previous step +
+

+ + Invite your team members + +

+

+ + Collaboration is tough by yourself. Invite a few team members using the invitation link below. + +

+
+ +
+
+ +
+
+
+
+
+
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap new file mode 100644 index 0000000000..0610646391 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/invite_members_link should match snapshot 1`] = ` +
+ +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap new file mode 100644 index 0000000000..cec545b0bd --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/organization_status should match snapshot 1`] = ` +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/index.tsx b/webapp/channels/src/components/preparing_workspace/index.tsx index a3ab4aa606..c454fd28ac 100644 --- a/webapp/channels/src/components/preparing_workspace/index.tsx +++ b/webapp/channels/src/components/preparing_workspace/index.tsx @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {Action} from 'mattermost-redux/types/actions'; -import {checkIfTeamExists, createTeam} from 'mattermost-redux/actions/teams'; +import {checkIfTeamExists, createTeam, updateTeam} from 'mattermost-redux/actions/teams'; import {getProfiles} from 'mattermost-redux/actions/users'; import PreparingWorkspace, {Actions} from './preparing_workspace'; @@ -13,6 +13,7 @@ import PreparingWorkspace, {Actions} from './preparing_workspace'; function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ + updateTeam, createTeam, getProfiles, checkIfTeamExists, diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.scss b/webapp/channels/src/components/preparing_workspace/invite_members.scss new file mode 100644 index 0000000000..dc914d42b1 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.scss @@ -0,0 +1,51 @@ +@import 'utils/mixins'; + +.InviteMembers-body { + display: flex; + // page width - channels preview width - progress dots width - people overlap width + max-width: calc(100vw - 600px - 120px - 30px); + + .UsersEmailsInput { + max-width: 420px; + } +} + +.InviteMembers { + &__submit { + display: flex; + align-items: center; + justify-content: flex-start; + } +} + +@include simple-in-and-out-before("InviteMembers"); + +.ChannelsPreview--enter-from-after { + &-enter { + transform: translateX(-100vw); + } + + &-enter-active { + transform: translateX(0); + transition: transform 300ms ease-in-out; + } + + &-enter-done { + transform: translateX(0); + } +} + +.ChannelsPreview--exit-to-after { + &-exit { + transform: translateX(0); + } + + &-exit-active { + transform: translateX(-100vw); + transition: transform 300ms ease-in-out; + } + + &-exit-done { + transform: translateX(-100vw); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx new file mode 100644 index 0000000000..54fe45f374 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ComponentProps} from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +import InviteMembers from './invite_members'; + +describe('InviteMembers component', () => { + let defaultProps: ComponentProps; + + beforeEach(() => { + defaultProps = { + disableEdits: false, + browserSiteUrl: 'https://my-org.mattermost.com', + formUrl: 'https://my-org.mattermost.com/signup', + teamInviteId: '1234', + className: 'test-class', + configSiteUrl: 'https://my-org.mattermost.com/config', + onPageView: jest.fn(), + previous:
{'Previous step'}
, + next: jest.fn(), + show: true, + transitionDirection: 'forward', + }; + }); + + it('should match snapshot', () => { + const component = withIntl(); + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders invite URL', () => { + const component = withIntl(); + render(component); + const inviteLink = screen.getByTestId('shareLinkInput'); + expect(inviteLink).toHaveAttribute( + 'value', + 'https://my-org.mattermost.com/config/signup_user_complete/?id=1234', + ); + }); + + it('renders submit button with correct text', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeInTheDocument(); + }); + + it('button is disabled when disableEdits is true', () => { + const component = withIntl( + , + ); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeDisabled(); + }); + + it('invokes next prop on button click', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + fireEvent.click(button); + expect(defaultProps.next).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.tsx new file mode 100644 index 0000000000..a018c2a446 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useEffect} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage} from 'react-intl'; + +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; + +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; +import SingleColumnLayout from './single_column_layout'; + +import InviteMembersLink from './invite_members_link'; +import PageLine from './page_line'; +import './invite_members.scss'; + +type Props = PreparingWorkspacePageProps & { + disableEdits: boolean; + className?: string; + teamInviteId?: string; + formUrl: Form['url']; + configSiteUrl?: string; + browserSiteUrl: string; +} + +const InviteMembers = (props: Props) => { + let className = 'InviteMembers-body'; + if (props.className) { + className += ' ' + props.className; + } + + useEffect(props.onPageView, []); + + const inviteURL = useMemo(() => { + let urlBase = ''; + if (props.configSiteUrl && !props.configSiteUrl.includes('localhost')) { + urlBase = props.configSiteUrl; + } else if (props.formUrl && !props.formUrl.includes('localhost')) { + urlBase = props.formUrl; + } else { + urlBase = props.browserSiteUrl; + } + return `${urlBase}/signup_user_complete/?id=${props.teamInviteId}`; + }, [props.teamInviteId, props.configSiteUrl, props.browserSiteUrl, props.formUrl]); + + const description = ( + + ); + + const inviteInteraction = ; + + return ( + +
+ + + {props.previous} + + <FormattedMessage + id={'onboarding_wizard.invite_members.title'} + defaultMessage='Invite your team members' + /> + + + {description} + + + {inviteInteraction} + +
+ +
+ +
+
+
+ ); +}; + +export default InviteMembers; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx new file mode 100644 index 0000000000..26b28e9b6f --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx @@ -0,0 +1,838 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {SVGProps} from 'react'; + +const InviteMembersIllustration = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default InviteMembersIllustration; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.scss b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss new file mode 100644 index 0000000000..09b229f264 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss @@ -0,0 +1,51 @@ +.InviteMembersLink { + display: flex; + + &__input { + height: 48px; + flex-grow: 1; + padding: 12px 14px; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-right: 0; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + background: rgba(var(--center-channel-color-rgb), 0.04); + border-radius: 4px 0 0 4px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 16px; + } + + &__button { + display: flex; + width: 180px; + max-width: 382px; + height: 48px; + flex-grow: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--button-bg); + background: var(--center-channel-bg); + border-radius: 0 4px 4px 0; + color: var(--button-bg); + font-size: 16px; + font-weight: 600; + + &:hover { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &:active { + background: rgba(var(--button-bg-rgb), 0.08); + } + + span { + display: inline-block; + height: 24px; + margin-right: 9px; + } + + svg { + fill: var(--button-bg); + } + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx new file mode 100644 index 0000000000..d74b81d493 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {trackEvent} from 'actions/telemetry_actions'; +import InviteMembersLink from './invite_members_link'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +jest.mock('actions/telemetry_actions', () => ({ + trackEvent: jest.fn(), +})); + +describe('components/preparing-workspace/invite_members_link', () => { + const inviteURL = 'https://invite-url.mattermost.com'; + + it('should match snapshot', () => { + const component = withIntl(); + + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders an input field with the invite URL', () => { + const component = withIntl(); + render(component); + const input = screen.getByDisplayValue(inviteURL); + expect(input).toBeInTheDocument(); + }); + + it('renders a button to copy the invite URL', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + expect(button).toBeInTheDocument(); + }); + + it('calls the trackEvent function when the copy button is clicked', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + fireEvent.click(button); + expect(trackEvent).toHaveBeenCalledWith( + 'first_admin_setup', + 'admin_setup_click_copy_invite_link', + ); + }); + + it('changes the button text to "Link Copied" when the URL is copied', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + const originalText = 'Copy Link'; + const linkCopiedText = 'Link Copied'; + expect(button).toHaveTextContent(originalText); + + fireEvent.click(button); + + expect(button).toHaveTextContent(linkCopiedText); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx new file mode 100644 index 0000000000..f6491809ca --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import useCopyText from 'components/common/hooks/useCopyText'; +import {trackEvent} from 'actions/telemetry_actions'; + +import './invite_members_link.scss'; + +type Props = { + inviteURL: string; +} + +const InviteMembersLink = (props: Props) => { + const copyText = useCopyText({ + trackCallback: () => trackEvent('first_admin_setup', 'admin_setup_click_copy_invite_link'), + text: props.inviteURL, + }); + const intl = useIntl(); + + return ( +
+ + +
+ ); +}; + +export default InviteMembersLink; diff --git a/webapp/channels/src/components/preparing_workspace/mixins.scss b/webapp/channels/src/components/preparing_workspace/mixins.scss new file mode 100644 index 0000000000..b3ca03bce8 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/mixins.scss @@ -0,0 +1,12 @@ +@mixin input { + width: 452px; + padding: 12px 16px; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + font-size: 16px; + + &:active, + &:focus { + border: 2px solid var(--button-bg); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/organization.scss b/webapp/channels/src/components/preparing_workspace/organization.scss new file mode 100644 index 0000000000..c063010404 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.scss @@ -0,0 +1,63 @@ +@import 'utils/variables'; +@import 'utils/mixins'; +@import './mixins'; + +.Organization-body { + display: flex; +} + +.Organization-form-wrapper { + position: relative; +} + +.Organization-left-col { + width: 210px; + min-width: 210px; +} + +.Organization-right-col { + display: flex; + flex-direction: column; + justify-content: center; +} + +.Organization { + &__input { + @include input; + } + + &__status { + display: flex; + align-items: center; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + + &--error { + margin-top: 8px; + color: var(--dnd-indicator); + } + } + + &__progress-path { + position: absolute; + top: -25px; + left: -55px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + text-align: center; + } + + &__content { + margin-left: 200px; + } +} + +@media screen and (max-width: 700px) { + .Organization-left-col { + display: none; + } +} + +@include simple-in-and-out("Organization"); diff --git a/webapp/channels/src/components/preparing_workspace/organization.tsx b/webapp/channels/src/components/preparing_workspace/organization.tsx new file mode 100644 index 0000000000..684c6dc4d9 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useRef, ChangeEvent} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import debounce from 'lodash/debounce'; + +import OrganizationSVG from 'components/common/svg_images_components/organization-building_svg'; +import QuickInput from 'components/quick_input'; + +import {trackEvent} from 'actions/telemetry_actions'; + +import {getTeams} from 'mattermost-redux/actions/teams'; +import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams'; +import {Team} from '@mattermost/types/teams'; + +import {teamNameToUrl} from 'utils/url'; +import Constants from 'utils/constants'; + +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; +import PageLine from './page_line'; +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; + +import './organization.scss'; + +type Props = PreparingWorkspacePageProps & { + organization: Form['organization']; + setOrganization: (organization: Form['organization']) => void; + className?: string; + createTeam: (OrganizationName: string) => Promise<{error: string | null; newTeam: Team | null}>; + updateTeam: (teamToUpdate: Team) => Promise<{error: string | null; updatedTeam: Team | null}>; + setInviteId: (inviteId: string) => void; +} + +const reportValidationError = debounce(() => { + trackEvent('first_admin_setup', 'validate_organization_error'); +}, 700, {leading: false}); + +const Organization = (props: Props) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + const [triedNext, setTriedNext] = useState(false); + const inputRef = useRef(); + const validation = teamNameToUrl(props.organization || ''); + const teamApiError = useRef(null); + + useEffect(props.onPageView, []); + + const teams = useSelector(getActiveTeamsList); + useEffect(() => { + if (!teams) { + dispatch(getTeams(0, 60)); + } + }, [teams]); + + const setApiCallError = () => { + teamApiError.current = TeamApiError; + }; + + const updateTeamNameFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + const currentTeam = teams[0]; + + if (currentTeam && name && name !== currentTeam.display_name) { + const {error} = await props.updateTeam({...currentTeam, display_name: name}); + if (error !== null) { + setApiCallError(); + } + } + }; + + const createTeamFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + if (name) { + const {error, newTeam} = await props.createTeam(name); + if (error !== null || newTeam === null) { + props.setInviteId(''); + setApiCallError(); + return; + } + props.setInviteId(newTeam.invite_id); + } + }; + + const handleOnChange = (e: ChangeEvent) => { + props.setOrganization(e.target.value); + teamApiError.current = null; + }; + + const onNext = (e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + if ((e as React.KeyboardEvent).key !== Constants.KeyCodes.ENTER[0]) { + return; + } + } + if (!triedNext) { + setTriedNext(true); + } + + // if there is already a team, maybe because a page reload, then just update the teamname + const thereIsAlreadyATeam = teams.length > 0; + teamApiError.current = null; + + if (!validation.error && !thereIsAlreadyATeam) { + createTeamFromOrgName(); + } else if (!validation.error && thereIsAlreadyATeam) { + updateTeamNameFromOrgName(); + } + + if (validation.error || teamApiError.current) { + reportValidationError(); + return; + } + props.next?.(); + }; + + let className = 'Organization-body'; + if (props.className) { + className += ' ' + props.className; + } + return ( + +
+
+
+
+ + +
+
+ {props.previous} + + <FormattedMessage + id={'onboarding_wizard.organization.title'} + defaultMessage='What’s the name of your organization?' + /> + + + + + + handleOnChange(e)} + onKeyUp={onNext} + autoFocus={true} + ref={inputRef as unknown as any} + /> + {triedNext ? : null} + + +
+
+
+
+
+ ); +}; +export default Organization; diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx new file mode 100644 index 0000000000..e7d65bfd6b --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render} from '@testing-library/react'; +import {BadUrlReasons} from 'utils/url'; +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +describe('components/preparing-workspace/organization_status', () => { + const defaultProps = { + error: null, + }; + + it('should match snapshot', () => { + const {container} = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render no error message when error prop is null', () => { + const {queryByText, container} = render(); + expect((container.getElementsByClassName('Organization__status').length)).toBe(1); + expect(queryByText(/empty/i)).not.toBeInTheDocument(); + expect(queryByText(/team api error/i)).not.toBeInTheDocument(); + expect(queryByText(/length/i)).not.toBeInTheDocument(); + expect(queryByText(/reserved/i)).not.toBeInTheDocument(); + }); + + it('should render an error message for an empty organization name', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/You must enter an organization name/i)).toBeInTheDocument(); + }); + + it('should render an error message for a team API error', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/There was an error, please try again/i)).toBeInTheDocument(); + }); + + it('should render an error message for an organization name with invalid length', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/Organization name must be between 2 and 64 characters/i)).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.tsx new file mode 100644 index 0000000000..d695a2ad26 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.tsx @@ -0,0 +1,83 @@ +// 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 {BadUrlReasons, UrlValidationCheck} from 'utils/url'; +import Constants, {DocLinks} from 'utils/constants'; +import ExternalLink from 'components/external_link'; + +export const TeamApiError = 'team_api_error'; + +const OrganizationStatus = (props: {error: (UrlValidationCheck['error'] | typeof TeamApiError | null)}): JSX.Element => { + let children = null; + let className = 'Organization__status'; + if (props.error) { + className += ' Organization__status--error'; + switch (props.error) { + case BadUrlReasons.Empty: + children = ( + + ); + break; + case TeamApiError: + children = ( + + ); + break; + case BadUrlReasons.Length: + children = ( + + ); + break; + case BadUrlReasons.Reserved: + children = ( + ( + + {chunks} + + ), + }} + /> + ); + break; + default: + children = ( + + ); + break; + } + } + return
{children}
; +}; + +export default OrganizationStatus; diff --git a/webapp/channels/src/components/preparing_workspace/page_line.scss b/webapp/channels/src/components/preparing_workspace/page_line.scss new file mode 100644 index 0000000000..12801e1f67 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.scss @@ -0,0 +1,10 @@ +.PageLine { + position: relative; + left: 100px; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.24); + + &--no-left { + left: initial; + } +} diff --git a/webapp/channels/src/components/preparing_workspace/page_line.tsx b/webapp/channels/src/components/preparing_workspace/page_line.tsx new file mode 100644 index 0000000000..ebbb9ee024 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import './page_line.scss'; + +type Props = { + style?: Record; + noLeft?: boolean; +} +const PageLine = (props: Props) => { + let className = 'PageLine'; + if (props.noLeft) { + className += ' PageLine--no-left'; + } + const styles: Record = {}; + if (props?.style) { + Object.assign(styles, props.style); + } + if (!styles.height) { + styles.height = '100vh'; + } + if ((!props.style?.height && styles.height === '100vh') && !styles.marginTop) { + styles.marginTop = '50px'; + } + return ( +
+ ); +}; + +export default PageLine; diff --git a/webapp/channels/src/components/preparing_workspace/plugins.scss b/webapp/channels/src/components/preparing_workspace/plugins.scss index fa74dc5718..0a5465564e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.scss +++ b/webapp/channels/src/components/preparing_workspace/plugins.scss @@ -4,6 +4,9 @@ margin-top: 24px; } +.plugins-skip-btn { + margin-left: 8px; +} // preempt cards wrapping @media screen and (max-width: 900px) { .Plugins-body { diff --git a/webapp/channels/src/components/preparing_workspace/plugins.tsx b/webapp/channels/src/components/preparing_workspace/plugins.tsx index b3b1168015..caf04e794e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.tsx +++ b/webapp/channels/src/components/preparing_workspace/plugins.tsx @@ -21,15 +21,16 @@ import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps import Title from './title'; import Description from './description'; import PageBody from './page_body'; - import SingleColumnLayout from './single_column_layout'; +import PageLine from './page_line'; import './plugins.scss'; type Props = PreparingWorkspacePageProps & { options: Form['plugins']; setOption: (option: keyof Form['plugins']) => void; className?: string; + isSelfHosted: boolean; } const Plugins = (props: Props) => { const {formatMessage} = useIntl(); @@ -44,6 +45,34 @@ const Plugins = (props: Props) => { if (props.className) { className += ' ' + props.className; } + + let title = ( + + ); + let description = ( + + ); + if (props.isSelfHosted) { + title = ( + + ); + description = ( + + ); + } + return ( { >
+ {props.previous} - <FormattedMessage - id={'onboarding_wizard.plugins.title'} - defaultMessage='Welcome to Mattermost!' - /> - <div className='subtitle'> - <CelebrateSVG/> - <FormattedMessage - id={'onboarding_wizard.plugins.subtitle'} - defaultMessage='(almost there!)' - /> - </div> + {title} + {!props.isSelfHosted && ( + <div className='subtitle'> + <CelebrateSVG/> + <FormattedMessage + id={'onboarding_wizard.cloud_plugins.subtitle'} + defaultMessage='(almost there!)' + /> + </div> + + )} - - - + {description} { />
+
diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss index c91dd0a1fe..99187c301b 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss @@ -63,6 +63,21 @@ .primary-button { @include primary-button; @include button-medium; + + box-sizing: border-box; + border: 2px solid var(--button-bg); + } + + .primary-button[disabled] { + box-sizing: border-box; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.01); + } + + .link-style { + @include link; + + background: transparent; + font-size: 14px; } .child-page { @@ -70,6 +85,43 @@ position: absolute; height: 100vh; } + + &__invite-members-illustration { + position: absolute; + top: 25%; + right: -651px; + animation-duration: 0.3s; + animation-fill-mode: forwards; + animation-timing-function: ease-in-out; + } +} + +.enter { + animation-name: slideInRight; +} + +.exit { + animation-name: slideOutRight; +} + +@keyframes slideInRight { + from { + right: -651px; + } + + to { + right: 0; + } +} + +@keyframes slideOutRight { + from { + right: 0; + } + + to { + right: -651px; + } } .PreparingWorkspacePageContainer { diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx index d554090d68..268e21c55e 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx @@ -1,23 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useCallback, useEffect, useRef} from 'react'; +import React, {useState, useCallback, useEffect, useRef, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {RouterProps} from 'react-router-dom'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {GeneralTypes} from 'mattermost-redux/action_types'; import {General} from 'mattermost-redux/constants'; import {getFirstAdminSetupComplete as getFirstAdminSetupCompleteAction} from 'mattermost-redux/actions/general'; import {ActionResult} from 'mattermost-redux/types/actions'; import {Team} from '@mattermost/types/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeam, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; -import {getFirstAdminSetupComplete, getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getFirstAdminSetupComplete, getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {Client4} from 'mattermost-redux/client'; import Constants from 'utils/constants'; +import {getSiteURL, teamNameToUrl} from 'utils/url'; +import {makeNewTeam} from 'utils/team_utils'; import {pageVisited, trackEvent} from 'actions/telemetry_actions'; @@ -35,10 +36,14 @@ import { mapStepToPageView, mapStepToSubmitFail, PLUGIN_NAME_TO_ID_MAP, + mapStepToPrevious, } from './steps'; +import Organization from './organization'; import Plugins from './plugins'; import Progress from './progress'; +import InviteMembers from './invite_members'; +import InviteMembersIllustration from './invite_members_illustration'; import LaunchingWorkspace, {START_TRANSITIONING_OUT} from './launching_workspace'; import './preparing_workspace.scss'; @@ -58,6 +63,7 @@ const WAIT_FOR_REDIRECT_TIME = 2000 - START_TRANSITIONING_OUT; export type Actions = { createTeam: (team: Team) => ActionResult; + updateTeam: (team: Team) => ActionResult; checkIfTeamExists: (teamName: string) => ActionResult; getProfiles: (page: number, perPage: number, options: Record) => ActionResult; } @@ -81,12 +87,16 @@ function makeSubmitFail(step: WizardStep) { } const trackSubmitFail = { + [WizardSteps.Organization]: makeSubmitFail(WizardSteps.Organization), [WizardSteps.Plugins]: makeSubmitFail(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeSubmitFail(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeSubmitFail(WizardSteps.LaunchingWorkspace), }; const onPageViews = { + [WizardSteps.Organization]: makeOnPageView(WizardSteps.Organization), [WizardSteps.Plugins]: makeOnPageView(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeOnPageView(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeOnPageView(WizardSteps.LaunchingWorkspace), }; @@ -98,28 +108,35 @@ const PreparingWorkspace = (props: Props) => { defaultMessage: 'Something went wrong. Please try again.', }); const isUserFirstAdmin = useSelector(isFirstAdmin); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const currentTeam = useSelector(getCurrentTeam); const myTeams = useSelector(getMyTeams); // In cloud instances created from portal, // new admin user has a team in myTeams but not in currentTeam. - const team = currentTeam || myTeams?.[0]; + let team = currentTeam || myTeams?.[0]; const config = useSelector(getConfig); const pluginsEnabled = config.PluginsEnabled === 'true'; const showOnMountTimeout = useRef(); + const configSiteUrl = config.SiteURL; + const isSelfHosted = useSelector(getLicense).Cloud !== 'true'; const stepOrder = [ + isSelfHosted && WizardSteps.Organization, pluginsEnabled && WizardSteps.Plugins, + isSelfHosted && WizardSteps.InviteMembers, WizardSteps.LaunchingWorkspace, ].filter((x) => Boolean(x)) as WizardStep[]; + // first steporder that is not false + const firstShowablePage = stepOrder[0]; + const firstAdminSetupComplete = useSelector(getFirstAdminSetupComplete); const [[mostRecentStep, currentStep], setStepHistory] = useState<[WizardStep, WizardStep]>([stepOrder[0], stepOrder[0]]); const [submissionState, setSubmissionState] = useState(SubmissionStates.Presubmit); + const browserSiteUrl = useMemo(getSiteURL, []); const [form, setForm] = useState({ ...emptyForm, }); @@ -188,13 +205,44 @@ const PreparingWorkspace = (props: Props) => { trackSubmitFail[redirectTo](); }, []); + const createTeam = async (OrganizationName: string): Promise<{error: string | null; newTeam: Team | null}> => { + const data = await props.actions.createTeam(makeNewTeam(OrganizationName, teamNameToUrl(OrganizationName || '').url)); + if (data.error) { + return {error: genericSubmitError, newTeam: null}; + } + return {error: null, newTeam: data.data}; + }; + + const updateTeam = async (teamToUpdate: Team): Promise<{error: string | null; updatedTeam: Team | null}> => { + const data = await props.actions.updateTeam(teamToUpdate); + if (data.error) { + return {error: genericSubmitError, updatedTeam: null}; + } + return {error: null, updatedTeam: data.data}; + }; + const sendForm = async () => { const sendFormStart = Date.now(); setSubmissionState(SubmissionStates.Submitting); + if (form.organization && !isSelfHosted) { + try { + const {error, newTeam} = await createTeam(form.organization); + if (error !== null) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + team = newTeam as Team; + } catch (e) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + } + // send plugins const {skipped: skippedPlugins, ...pluginChoices} = form.plugins; let pluginsToSetup: string[] = []; + if (!skippedPlugins) { pluginsToSetup = Object.entries(pluginChoices).reduce( (acc: string[], [k, v]): string[] => (v ? [...acc, PLUGIN_NAME_TO_ID_MAP[k as keyof Omit]] : acc), [], @@ -204,8 +252,10 @@ const PreparingWorkspace = (props: Props) => { // This endpoint sets setup complete state, so we need to make this request // even if admin skipped submitting plugins. const completeSetupRequest = { + organization: form.organization, install_plugins: pluginsToSetup, }; + try { await Client4.completeSetup(completeSetupRequest); dispatch({type: GeneralTypes.FIRST_ADMIN_COMPLETE_SETUP_RECEIVED, data: true}); @@ -221,6 +271,7 @@ const PreparingWorkspace = (props: Props) => { const sendFormEnd = Date.now(); const timeToWait = WAIT_FOR_REDIRECT_TIME - (sendFormEnd - sendFormStart); + if (timeToWait > 0) { setTimeout(goToChannels, timeToWait); } else { @@ -236,7 +287,8 @@ const PreparingWorkspace = (props: Props) => { }, [submissionState]); const adminRevisitedPage = firstAdminSetupComplete && submissionState === SubmissionStates.Presubmit; - const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage || !useCaseOnboarding; + const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage; + useEffect(() => { if (shouldRedirect) { props.history.push('/'); @@ -256,6 +308,24 @@ const PreparingWorkspace = (props: Props) => { return stepIndex > currentStepIndex ? Animations.Reasons.ExitToBefore : Animations.Reasons.ExitToAfter; }; + const goPrevious = useCallback((e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + const key = (e as React.KeyboardEvent).key; + if (key !== Constants.KeyCodes.ENTER[0] && key !== Constants.KeyCodes.SPACE[0]) { + return; + } + } + if (submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail) { + return; + } + const stepIndex = stepOrder.indexOf(currentStep); + if (stepIndex <= 0) { + return; + } + trackEvent('first_admin_setup', mapStepToPrevious(currentStep)); + setStepHistory([currentStep, stepOrder[stepIndex - 1]]); + }, [currentStep]); + const skipPlugins = useCallback((skipped: boolean) => { if (skipped === form.plugins.skipped) { return; @@ -269,6 +339,46 @@ const PreparingWorkspace = (props: Props) => { }); }, [form]); + const skipTeamMembers = useCallback((skipped: boolean) => { + if (skipped === form.teamMembers.skipped) { + return; + } + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + skipped, + }, + }); + }, [form]); + + const getInviteMembersAnimationClass = useCallback(() => { + if (currentStep === WizardSteps.InviteMembers) { + return 'enter'; + } else if (mostRecentStep === WizardSteps.InviteMembers) { + return 'exit'; + } + return ''; + }, [currentStep]); + + let previous: React.ReactNode = ( +
+ + +
+ ); + if (currentStep === firstShowablePage) { + previous = null; + } + return (
{submissionState === SubmissionStates.SubmitFail && submitError && ( @@ -291,17 +401,49 @@ const PreparingWorkspace = (props: Props) => { transitionSpeed={Animations.PAGE_SLIDE} />
+ { + setForm({ + ...form, + organization, + }); + }} + setInviteId={(inviteId: string) => { + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + inviteId, + }, + }); + }} + className='child-page' + createTeam={createTeam} + updateTeam={updateTeam} + /> + { const pluginChoices = {...form.plugins}; delete pluginChoices.skipped; - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins)(pluginChoices); skipPlugins(false); }} skip={() => { - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins, true)(); skipPlugins(true); }} @@ -319,12 +461,40 @@ const PreparingWorkspace = (props: Props) => { transitionDirection={getTransitionDirection(WizardSteps.Plugins)} className='child-page' /> + { + skipTeamMembers(false); + const inviteMembersTracking = { + inviteCount: form.teamMembers.invites.length, + }; + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers)(inviteMembersTracking); + }} + skip={() => { + skipTeamMembers(true); + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers, true)(); + }} + previous={previous} + show={shouldShowPage(WizardSteps.InviteMembers)} + transitionDirection={getTransitionDirection(WizardSteps.InviteMembers)} + disableEdits={submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail} + className='child-page' + teamInviteId={team?.invite_id || form.teamMembers.inviteId} + configSiteUrl={configSiteUrl} + formUrl={form.url} + browserSiteUrl={browserSiteUrl} + />
+
+ +
); }; diff --git a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss index 357faabdf4..afff27dcfe 100644 --- a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss +++ b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss @@ -4,5 +4,4 @@ height: 100vh; flex-direction: column; align-items: flex-start; - justify-content: center; } diff --git a/webapp/channels/src/components/preparing_workspace/steps.ts b/webapp/channels/src/components/preparing_workspace/steps.ts index ed52d984af..cbb78da5b6 100644 --- a/webapp/channels/src/components/preparing_workspace/steps.ts +++ b/webapp/channels/src/components/preparing_workspace/steps.ts @@ -4,7 +4,9 @@ import deepFreeze from 'mattermost-redux/utils/deep_freeze'; export const WizardSteps = { + Organization: 'Organization', Plugins: 'Plugins', + InviteMembers: 'InviteMembers', LaunchingWorkspace: 'LaunchingWorkspace', } as const; @@ -20,8 +22,12 @@ export const Animations = { export function mapStepToNextName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_next_organization'; case WizardSteps.Plugins: return 'admin_onboarding_next_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_next_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_next_transitioning_out'; default: @@ -31,8 +37,12 @@ export function mapStepToNextName(step: WizardStep): string { export function mapStepToPrevious(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_previous_organization'; case WizardSteps.Plugins: return 'admin_onboarding_previous_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_previous_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_previous_transitioning_out'; default: @@ -42,8 +52,12 @@ export function mapStepToPrevious(step: WizardStep): string { export function mapStepToPageView(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'pageview_admin_onboarding_organization'; case WizardSteps.Plugins: return 'pageview_admin_onboarding_plugins'; + case WizardSteps.InviteMembers: + return 'pageview_admin_onboarding_invite_members'; case WizardSteps.LaunchingWorkspace: return 'pageview_admin_onboarding_transitioning_out'; default: @@ -53,8 +67,12 @@ export function mapStepToPageView(step: WizardStep): string { export function mapStepToSubmitFail(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_organization_submit_fail'; case WizardSteps.Plugins: return 'admin_onboarding_plugins_submit_fail'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_invite_members_submit_fail'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_transitioning_out_submit_fail'; default: @@ -64,8 +82,12 @@ export function mapStepToSubmitFail(step: WizardStep): string { export function mapStepToSkipName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_skip_organization'; case WizardSteps.Plugins: return 'admin_onboarding_skip_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_skip_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_skip_transitioning_out'; default: @@ -128,12 +150,14 @@ export type Form = { skipped: boolean; }; teamMembers: { + inviteId: string; invites: string[]; skipped: boolean; }; } export const emptyForm = deepFreeze({ + organization: '', inferredProtocol: null, urlSkipped: false, useCase: { @@ -156,6 +180,7 @@ export const emptyForm = deepFreeze({ skipped: false, }, teamMembers: { + inviteId: '', invites: [], skipped: false, }, @@ -165,7 +190,7 @@ export type PreparingWorkspacePageProps = { transitionDirection: AnimationReason; next?: () => void; skip?: () => void; - previous?: JSX.Element; + previous?: React.ReactNode; show: boolean; onPageView: () => void; } diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index eb41c2d0fe..73f78f6af6 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -10,7 +10,7 @@ import classNames from 'classnames'; import {Client4} from 'mattermost-redux/client'; import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder'; import {General} from 'mattermost-redux/constants'; -import {Theme, getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser, isCurrentUserSystemAdmin, checkIsFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {setUrl} from 'mattermost-redux/actions/general'; @@ -89,6 +89,8 @@ import {ActionResult} from 'mattermost-redux/types/actions'; import WelcomePostRenderer from 'components/welcome_post_renderer'; +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + import {applyLuxonDefaults} from './effects'; import RootProvider from './root_provider'; @@ -358,8 +360,8 @@ export default class Root extends React.PureComponent { return; } - const useCaseOnboarding = getUseCaseOnboarding(storeState); - if (!useCaseOnboarding) { + const myTeams = getMyTeams(storeState); + if (myTeams.length > 0) { GlobalActions.redirectUserToDefaultTeam(); return; } diff --git a/webapp/channels/src/components/root/root_redirect/index.ts b/webapp/channels/src/components/root/root_redirect/index.ts index 7575f7c5a4..eca15abc20 100644 --- a/webapp/channels/src/components/root/root_redirect/index.ts +++ b/webapp/channels/src/components/root/root_redirect/index.ts @@ -6,7 +6,6 @@ import {connect} from 'react-redux'; import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general'; import {getCurrentUserId, isCurrentUserSystemAdmin, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GenericAction} from 'mattermost-redux/types/actions'; import {GlobalState} from 'types/store'; @@ -14,11 +13,7 @@ import {GlobalState} from 'types/store'; import RootRedirect, {Props} from './root_redirect'; function mapStateToProps(state: GlobalState) { - const useCaseOnboarding = getUseCaseOnboarding(state); - let isElegibleForFirstAdmingOnboarding = useCaseOnboarding; - if (isElegibleForFirstAdmingOnboarding) { - isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); - } + const isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); return { currentUserId: getCurrentUserId(state), isElegibleForFirstAdmingOnboarding, diff --git a/webapp/channels/src/components/signup/signup.test.tsx b/webapp/channels/src/components/signup/signup.test.tsx index dcc56032a0..900e8de8b7 100644 --- a/webapp/channels/src/components/signup/signup.test.tsx +++ b/webapp/channels/src/components/signup/signup.test.tsx @@ -7,8 +7,6 @@ import {IntlProvider} from 'react-intl'; import {BrowserRouter} from 'react-router-dom'; import {act, screen} from '@testing-library/react'; -import * as global_actions from 'actions/global_actions'; - import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import Signup from 'components/signup/signup'; @@ -197,9 +195,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({error: {server_error_id: 'api.user.login.not_verified.app_error'}}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -228,7 +223,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - expect(mockRedirectUserToDefaultTeam).not.toHaveBeenCalled(); expect(mockHistoryPush).toHaveBeenCalledWith('/should_verify_email?email=jdoe%40mm.com&teamname=teamName'); }); @@ -238,9 +232,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -268,8 +259,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find(Input).first().props().disabled).toEqual(true); expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - - expect(mockRedirectUserToDefaultTeam).toHaveBeenCalled(); }); it('should add user to team and redirect when team invite valid and logged in', async () => { diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 454d5cd8ae..0407a74895 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -17,7 +17,7 @@ import {getTeamInviteInfo} from 'mattermost-redux/actions/teams'; import {createUser, loadMe, loadMeREST} from 'mattermost-redux/actions/users'; import {DispatchFunc} from 'mattermost-redux/types/actions'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {isEmail} from 'mattermost-redux/utils/helpers'; @@ -25,7 +25,6 @@ import {GlobalState} from 'types/store'; import {getGlobalItem} from 'selectors/storage'; -import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {removeGlobalItem, setGlobalItem} from 'actions/storage'; import {addUserToTeamFromInvite} from 'actions/team_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; @@ -104,7 +103,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } = config; const {IsLicensed, Cloud} = useSelector(getLicense); const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined)); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -310,15 +308,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } else if (inviteId) { getInviteInfo(inviteId); } else if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first tiem onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - } else { - redirectUserToDefaultTeam(); - } + history.push('/'); } } @@ -461,14 +451,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { if (redirectTo) { history.push(redirectTo); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first tiem onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/terms_of_service/index.ts b/webapp/channels/src/components/terms_of_service/index.ts index 95faca09a1..c22fba415f 100644 --- a/webapp/channels/src/components/terms_of_service/index.ts +++ b/webapp/channels/src/components/terms_of_service/index.ts @@ -6,7 +6,6 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; import {getTermsOfService, updateMyTermsOfServiceStatus} from 'mattermost-redux/actions/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GlobalState} from '@mattermost/types/store'; import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; @@ -26,9 +25,7 @@ type Actions = { function mapStateToProps(state: GlobalState) { const config = getConfig(state); - const useCaseOnboarding = getUseCaseOnboarding(state); return { - useCaseOnboarding, termsEnabled: config.EnableCustomTermsOfService === 'true', emojiMap: getEmojiMap(state), }; diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx index d05c91cd84..5c9f58faab 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx @@ -27,7 +27,6 @@ describe('components/terms_of_service/TermsOfService', () => { location: {search: ''}, termsEnabled: true, emojiMap: {} as EmojiMap, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx index 992086d561..f885830f9c 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx @@ -38,7 +38,6 @@ export interface TermsOfServiceProps { ) => {data: UpdateMyTermsOfServiceStatusResponse}; }; emojiMap: EmojiMap; - useCaseOnboarding: boolean; } interface TermsOfServiceState { @@ -111,14 +110,12 @@ export default class TermsOfService extends React.PureComponentstart with a reserved word.", + "onboarding_wizard.organization.team_api_error": "There was an error, please try again.", + "onboarding_wizard.organization.title": "What’s the name of your organization?", "onboarding_wizard.plugins.github": "GitHub", "onboarding_wizard.plugins.github.tooltip": "Subscribe to repositories, stay up to date with reviews, assignments", "onboarding_wizard.plugins.gitlab": "GitLab", @@ -4329,13 +4345,14 @@ "onboarding_wizard.plugins.jira": "Jira", "onboarding_wizard.plugins.jira.tooltip": "Create Jira tickets from messages in Mattermost, get notified of important updates in Jira", "onboarding_wizard.plugins.marketplace": "More tools can be added once your workspace is set up. To see all available integrations, visit the Marketplace.", - "onboarding_wizard.plugins.subtitle": "(almost there!)", - "onboarding_wizard.plugins.title": "Welcome to Mattermost!", "onboarding_wizard.plugins.todo": "To do", "onboarding_wizard.plugins.todo.tooltip": "A plugin to track Todo issues in a list and send you daily reminders about your Todo list", "onboarding_wizard.plugins.zoom": "Zoom", "onboarding_wizard.plugins.zoom.tooltip": "Start Zoom audio and video conferencing calls in Mattermost with a single click", - "onboarding_wizard.skip": "Skip for now", + "onboarding_wizard.previous": "Previous", + "onboarding_wizard.self_hosted_plugins.description": "Choose the tools you work with, and we'll add them to your workspace. Additional set up may be needed later.", + "onboarding_wizard.self_hosted_plugins.title": "What tools do you use?", + "onboarding_wizard.skip-button": "Skip", "onboarding_wizard.submit_error.generic": "Something went wrong. Please try again.", "onboardingTask.checklist.completed_subtitle": "We hope Mattermost is more familiar now.", "onboardingTask.checklist.completed_title": "Well done. You’ve completed all of the tasks!", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index 2ef11c99d3..b2d3c1a672 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -245,10 +245,6 @@ export function isCustomGroupsEnabled(state: GlobalState): boolean { return getConfig(state).EnableCustomGroups === 'true'; } -export function getUseCaseOnboarding(state: GlobalState): boolean { - return getFeatureFlagValue(state, 'UseCaseOnboarding') === 'true' && getLicense(state)?.Cloud === 'true'; -} - export function insightsAreEnabled(state: GlobalState): boolean { const isConfiguredForFeature = getConfig(state).InsightsEnabled === 'true'; const featureIsEnabled = getFeatureFlagValue(state, 'InsightsEnabled') === 'true'; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 62fc2e8fdd..7f1dce9244 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1099,6 +1099,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', + ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url', }; export const LicenseLinks = { diff --git a/webapp/platform/types/src/setup.ts b/webapp/platform/types/src/setup.ts index 980aa05dce..085527a434 100644 --- a/webapp/platform/types/src/setup.ts +++ b/webapp/platform/types/src/setup.ts @@ -2,5 +2,6 @@ // See LICENSE.txt for license information. export type CompleteOnboardingRequest = { + organization: string; install_plugins: string[]; } From 1f5781905435d870c7342fa629284a508decf18f Mon Sep 17 00:00:00 2001 From: Tanmay Datta Date: Wed, 19 Apr 2023 14:59:49 +0100 Subject: [PATCH 075/113] [MM-51089] Fix sorting value of category in CreateSidebarCategoryForTeamForUser (#22455) Co-authored-by: Mattermost Build --- .../sqlstore/channel_store_categories.go | 2 +- .../storetest/channel_store_categories.go | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/server/channels/store/sqlstore/channel_store_categories.go b/server/channels/store/sqlstore/channel_store_categories.go index 3f2f726a0f..4eca0d5de0 100644 --- a/server/channels/store/sqlstore/channel_store_categories.go +++ b/server/channels/store/sqlstore/channel_store_categories.go @@ -335,7 +335,7 @@ func (s SqlChannelStore) CreateSidebarCategory(userId, teamId string, newCategor Id: newCategoryId, UserId: userId, TeamId: teamId, - Sorting: model.SidebarCategorySortDefault, + Sorting: newCategory.Sorting, SortOrder: int64(model.MinimalSidebarSortDistance * len(newOrder)), // first we place it at the end of the list Type: model.SidebarCategoryCustom, Muted: newCategory.Muted, diff --git a/server/channels/store/storetest/channel_store_categories.go b/server/channels/store/storetest/channel_store_categories.go index ecd49ef8c2..6ba934f45b 100644 --- a/server/channels/store/storetest/channel_store_categories.go +++ b/server/channels/store/storetest/channel_store_categories.go @@ -672,6 +672,38 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NoError(t, err) assert.Equal(t, []string{}, res2.Channels) }) + + t.Run("should store the correct sorting value", func(t *testing.T) { + userId := model.NewId() + + team := setupTeam(t, ss, userId) + + opts := &store.SidebarCategorySearchOpts{ + TeamID: team.Id, + ExcludeTeam: false, + } + res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) + require.NoError(t, nErr) + require.NotEmpty(t, res) + // Create the category + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + DisplayName: model.NewId(), + Sorting: model.SidebarCategorySortManual, + }, + }) + require.NoError(t, err) + + // Confirm that sorting value is correct + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) + require.NoError(t, err) + require.Len(t, res.Categories, 4) + // first category will be favorites and second will be newly created + assert.Equal(t, model.SidebarCategoryCustom, res.Categories[1].Type) + assert.Equal(t, created.Id, res.Categories[1].Id) + assert.Equal(t, model.SidebarCategorySortManual, res.Categories[1].Sorting) + assert.Equal(t, model.SidebarCategorySortManual, created.Sorting) + }) } func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { From d1c9469d067d5b64c50e83227c4806b350752109 Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Wed, 19 Apr 2023 20:01:50 +0530 Subject: [PATCH 076/113] Add CODEOWNERS for migrations (#23020) ```release-note NONE ``` --- CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index b0bd218122..4ef5c97c43 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,3 +5,6 @@ /webapp/package-lock.json @mattermost/web-platform /webapp/platform/*/package.json @mattermost/web-platform /webapp/scripts @mattermost/web-platform +/server/channels/db/migrations @mattermost/server-platform +/server/boards/services/store/sqlstore/migrations @mattermost/server-platform +/server/playbooks/server/sqlstore/migrations @mattermost/server-platform From 6d62acb13e9b8be26a101150c249693cfb2c4fc4 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 19 Apr 2023 12:03:06 -0400 Subject: [PATCH 077/113] remove exta background svg. --- .../self_hosted_expansion_modal/success_page.tsx | 3 --- 1 file changed, 3 deletions(-) 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 index 9549ef2e24..c82df09951 100644 --- 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 @@ -71,9 +71,6 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { formattedButtonText={formattedButtonText} buttonHandler={props.onClose} /> -
- -
); } From 4a77773774045ebce705e3d1affa40b0b6f12789 Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Wed, 19 Apr 2023 12:12:52 -0400 Subject: [PATCH 078/113] Remove import of background svg --- .../self_hosted_expansion_modal/success_page.tsx | 1 - 1 file changed, 1 deletion(-) 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 index c82df09951..f8c362780c 100644 --- 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 @@ -7,7 +7,6 @@ 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 BackgroundSvg from 'components/common/svg_images_components/background_svg'; import {ConsolePages} from 'utils/constants'; From 96ad240a78f181305dddb34a98a61960ff4f7a71 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 19 Apr 2023 10:45:23 -0600 Subject: [PATCH 079/113] MM-51876 - use redirect if user already logged in (#22997) * use redirect if user already logged in * lint fixes --------- Co-authored-by: Mattermost Build --- .../channels/src/components/login/login.test.tsx | 14 ++++++++++++++ webapp/channels/src/components/login/login.tsx | 10 +++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/login/login.test.tsx b/webapp/channels/src/components/login/login.test.tsx index b1116f4d2c..512c98856f 100644 --- a/webapp/channels/src/components/login/login.test.tsx +++ b/webapp/channels/src/components/login/login.test.tsx @@ -288,4 +288,18 @@ describe('components/login/Login', () => { expect(externalLoginButton.props().label).toEqual('OpenID 2'); expect(externalLoginButton.props().style).toEqual({color: '#00ff00', borderColor: '#00ff00'}); }); + + it('should redirect on login', () => { + mockState.entities.users.currentUserId = 'user1'; + LocalStorageStore.setWasLoggedIn(true); + mockConfig.EnableSignInWithEmail = 'true'; + const redirectPath = '/boards/team/teamID/boardID'; + mockLocation.search = '?redirect_to=' + redirectPath; + mount( + + + , + ); + expect(mockHistoryPush).toHaveBeenCalledWith(redirectPath); + }); }); diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index c0e154e561..edde751e63 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -141,6 +141,9 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const enableExternalSignup = enableSignUpWithGitLab || enableSignUpWithOffice365 || enableSignUpWithGoogle || enableSignUpWithOpenId || enableSignUpWithSaml; const showSignup = enableOpenServer && (enableExternalSignup || enableSignUpWithEmail || enableLdap); + const query = new URLSearchParams(search); + const redirectTo = query.get('redirect_to'); + const getExternalLoginOptions = () => { const externalLoginOptions: ExternalLoginButtonType[] = []; @@ -372,6 +375,10 @@ const Login = ({onCustomizeHeader}: LoginProps) => { useEffect(() => { if (currentUser) { + if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) { + history.push(redirectTo); + return; + } redirectUserToDefaultTeam(); return; } @@ -615,9 +622,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => { dispatch(setNeedsLoggedInLimitReachedCheck(true)); } - const query = new URLSearchParams(search); - const redirectTo = query.get('redirect_to'); - setCSRFFromCookie(); // Record a successful login to local storage. If an unintentional logout occurs, e.g. From 0a2a39773c6a319e3a446904c7347b67c9526033 Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Wed, 19 Apr 2023 10:38:56 -0700 Subject: [PATCH 080/113] [MM-52152] Expose license SkuShortName to all users (#22955) --- server/channels/app/license_test.go | 2 -- server/channels/app/platform/license_test.go | 2 -- server/channels/utils/license.go | 1 - 3 files changed, 5 deletions(-) diff --git a/server/channels/app/license_test.go b/server/channels/app/license_test.go index 7b32ee52e6..ea6bbcf7eb 100644 --- a/server/channels/app/license_test.go +++ b/server/channels/app/license_test.go @@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) { assert.False(t, ok) _, ok = m["SkuName"] assert.False(t, ok) - _, ok = m["SkuShortName"] - assert.False(t, ok) } func TestGenerateRenewalToken(t *testing.T) { diff --git a/server/channels/app/platform/license_test.go b/server/channels/app/platform/license_test.go index 6682348130..258c2fbe38 100644 --- a/server/channels/app/platform/license_test.go +++ b/server/channels/app/platform/license_test.go @@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) { assert.False(t, ok) _, ok = m["SkuName"] assert.False(t, ok) - _, ok = m["SkuShortName"] - assert.False(t, ok) } func TestGenerateRenewalToken(t *testing.T) { diff --git a/server/channels/utils/license.go b/server/channels/utils/license.go index b937662f35..43f1f8a0ba 100644 --- a/server/channels/utils/license.go +++ b/server/channels/utils/license.go @@ -210,7 +210,6 @@ func GetSanitizedClientLicense(l map[string]string) map[string]string { delete(sanitizedLicense, "StartsAt") delete(sanitizedLicense, "ExpiresAt") delete(sanitizedLicense, "SkuName") - delete(sanitizedLicense, "SkuShortName") return sanitizedLicense } From feab1bf61bbbd845925add8d489bf02123fa71d6 Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Wed, 19 Apr 2023 14:52:21 -0700 Subject: [PATCH 081/113] [MM-51805 + MM-51806 + MM-51041] Work template: multiple UI fixes (#22862) --- .../work_templates/components/customize.tsx | 3 +- .../work_templates/components/preview.tsx | 13 ++-- .../components/preview/accordion.tsx | 3 +- .../components/preview/section.tsx | 61 ++++++++++++++----- webapp/channels/src/i18n/en.json | 3 + 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/webapp/channels/src/components/work_templates/components/customize.tsx b/webapp/channels/src/components/work_templates/components/customize.tsx index 6d5639308c..07569122e9 100644 --- a/webapp/channels/src/components/work_templates/components/customize.tsx +++ b/webapp/channels/src/components/work_templates/components/customize.tsx @@ -165,7 +165,8 @@ const StyledCustomized = styled(Customize)` border-radius: 4px; border: 1px solid rgba(var(--center-channel-text-rgb), 0.16); &:focus { - border: 2px solid var(--button-bg); + border: 1px solid var(--button-bg); + box-shadow: inset 0 0 0 1px var(--button-bg); } } diff --git a/webapp/channels/src/components/work_templates/components/preview.tsx b/webapp/channels/src/components/work_templates/components/preview.tsx index 9e7ab6438e..14ea11265d 100644 --- a/webapp/channels/src/components/work_templates/components/preview.tsx +++ b/webapp/channels/src/components/work_templates/components/preview.tsx @@ -51,7 +51,8 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { const [integrations, setIntegrations] = useState(); - const plugins: MarketplacePlugin[] = useSelector((state: GlobalState) => state.views.marketplace.plugins); + const marketplacePlugins: MarketplacePlugin[] = useSelector((state: GlobalState) => state.views.marketplace.plugins); + const loadedPlugins = useSelector((state: GlobalState) => state.plugins.plugins); const [illustrationDetails, setIllustrationDetails] = useState(() => { const defaultIllustration = getTemplateDefaultIllustration(template); @@ -130,13 +131,14 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { const intg = availableIntegrations?. flatMap((integration) => { - return plugins.reduce((acc: Integration[], curr) => { + return marketplacePlugins.reduce((acc: Integration[], curr) => { if (curr.manifest.id === integration.id) { + const installed = Boolean(loadedPlugins[integration.id]); acc.push({ ...integration, name: curr.manifest.name, icon: curr.icon_data, - installed: curr.installed_version !== '', + installed, }); return acc; @@ -149,7 +151,7 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { if (intg?.length) { setIntegrations(intg); } - }, [plugins, availableIntegrations, pluginsEnabled]); + }, [marketplacePlugins, availableIntegrations, loadedPlugins, pluginsEnabled]); // building accordion items const accordionItemsData: AccordionItemType[] = []; @@ -204,7 +206,7 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { )], }); } - if (integrations?.length && pluginsEnabled) { + if (pluginsEnabled && integrations?.length) { accordionItemsData.push({ id: 'integrations', icon: , @@ -303,6 +305,7 @@ const StyledPreview = styled(Preview)` width: 387px; height: 416px; padding-right: 32px; + margin-top: 17px; } strong { diff --git a/webapp/channels/src/components/work_templates/components/preview/accordion.tsx b/webapp/channels/src/components/work_templates/components/preview/accordion.tsx index 41394b9dc4..02be467269 100644 --- a/webapp/channels/src/components/work_templates/components/preview/accordion.tsx +++ b/webapp/channels/src/components/work_templates/components/preview/accordion.tsx @@ -12,6 +12,7 @@ const Accordion = styled(LibAccordion)` .accordion-card { margin-bottom: 8px; border-radius: 4px; + border: 1px solid transparent; color: var(--center-channel-color); .accordion-card-header { @@ -46,7 +47,7 @@ const Accordion = styled(LibAccordion)` } &.active { - border: 1px solid var(--denim-button-bg); + border-color: var(--denim-button-bg); .accordion-card-header { color: var(--denim-button-bg); diff --git a/webapp/channels/src/components/work_templates/components/preview/section.tsx b/webapp/channels/src/components/work_templates/components/preview/section.tsx index 00ee7e8f13..3514da9b09 100644 --- a/webapp/channels/src/components/work_templates/components/preview/section.tsx +++ b/webapp/channels/src/components/work_templates/components/preview/section.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useEffect, useState} from 'react'; +import React, {ReactNode, useCallback, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; import classnames from 'classnames'; import styled from 'styled-components'; @@ -110,6 +110,28 @@ const IntegrationsPreview = ({items, categoryId}: IntegrationPreviewSectionProps id: 'work_templates.preview.integrations.admin_install.notify', defaultMessage: 'Notify admin to install integrations.', }); + + const makeIntegrationSubtext = useCallback((integration: IntegrationPreviewSectionItemsProps) => { + if (integration.installed) { + return formatMessage({ + id: 'work_templates.preview.integrations.already_installed', + defaultMessage: 'Already installed', + }); + } + + if (!pluginInstallationPossible) { + return formatMessage({ + id: 'work_templates.preview.integrations.app_install', + defaultMessage: 'App Install', + }); + } + + return formatMessage({ + id: 'work_templates.preview.integrations.to_be_installed', + defaultMessage: 'To be installed', + }); + }, [pluginInstallationPossible, formatMessage]); + return (
@@ -119,15 +141,17 @@ const IntegrationsPreview = ({items, categoryId}: IntegrationPreviewSectionProps key={item.id} className={classnames('preview-integrations-plugins-item', {'preview-integrations-plugins-item__readonly': !item.installed && !pluginInstallationPossible})} > -
+
- {item.name} + {item.name}
+ + {makeIntegrationSubtext(item)} +
{item.installed && -
} - {!item.installed &&
} +
}
); })}
@@ -205,6 +229,7 @@ const StyledPreviewSection = styled(PreviewSection)` &-item { display: flex; + align-items: center; width: 128px; height: 48px; flex-basis: 45%; @@ -215,7 +240,7 @@ const StyledPreviewSection = styled(PreviewSection)` opacity: 65%; } - &__icon { + &__illustration { display: flex; width: 24px; height: 24px; @@ -227,22 +252,30 @@ const StyledPreviewSection = styled(PreviewSection)` width: 100%; height: 100%; } - - &_blue { - color: var(--denim-button-bg); - } } &__name { flex-grow: 2; - margin-top: 8px; color: var(--center-channel-text); font-family: 'Open Sans'; font-size: 11px; font-style: normal; font-weight: 600; letter-spacing: 0.02em; - line-height: 22px; + line-height: 16px; + &-sub { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-weight: 400; + font-size: 10px; + } + } + + &__icon { + align-self: flex-start; + + &_blue { + color: var(--denim-button-bg); + } } } } @@ -264,8 +297,8 @@ const StyledPreviewSection = styled(PreviewSection)` } .icon-check-circle::before { - margin-top: 8px; - margin-right: 8px; + margin-top: 2px; + margin-right: 2px; } .icon-download-outline::before { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 88de5a0b7c..c5af857223 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5784,6 +5784,9 @@ "work_templates.preview.integrations.admin_install.multiple_plugin": "Integrations will not be added until admin installs them.", "work_templates.preview.integrations.admin_install.notify": "Notify admin to install integrations", "work_templates.preview.integrations.admin_install.single_plugin": "{plugin} will not be added until admin installs it.", + "work_templates.preview.integrations.already_installed": "Already installed", + "work_templates.preview.integrations.app_install": "App Install", + "work_templates.preview.integrations.to_be_installed": "To be installed", "work_templates.preview.modal_cancel_button": "Back", "work_templates.preview.modal_next_button": "Next", "work_templates.preview.modal_title": "Preview {useCase}", From 09ac549d0b1996795d6fe03ada17af997134bcb4 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 20 Apr 2023 08:31:09 +0530 Subject: [PATCH 082/113] Revert "MM-50123 : Identify causes of removal of channel and channel members re fetching on team switch (#22984)" (#23038) This reverts commit 5d5c1d90bfe1cebe117b5221487cf73b0dc1ad4c. --- .../channels/src/actions/channel_actions.ts | 21 +++++++++++-------- .../team_controller/actions/index.ts | 17 ++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index c800d0fda1..b3c9ffb156 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -254,22 +254,25 @@ export function fetchChannelsAndMembers(teamId: Team['id'] = ''): ActionFunc<{ch teamId, data: channels, }); + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, + data: channelMembers, + }); + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: roles, + }); } else { actions.push({ type: ChannelTypes.RECEIVED_ALL_CHANNELS, data: channels, }); + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, + data: channelMembers, + }); } - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, - data: channelMembers, - }); - actions.push({ - type: RoleTypes.RECEIVED_ROLES, - data: roles, - }); - await dispatch(batchActions(actions)); return {data: {channels, channelMembers, roles}}; diff --git a/webapp/channels/src/components/team_controller/actions/index.ts b/webapp/channels/src/components/team_controller/actions/index.ts index e1717d0dbc..38977f2678 100644 --- a/webapp/channels/src/components/team_controller/actions/index.ts +++ b/webapp/channels/src/components/team_controller/actions/index.ts @@ -4,9 +4,10 @@ import {ActionFunc} from 'mattermost-redux/types/actions'; import {getTeamByName, selectTeam} from 'mattermost-redux/actions/teams'; import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {fetchMyChannelsAndMembersREST} from 'mattermost-redux/actions/channels'; import {getGroups, getAllGroupsAssociatedToChannelsInTeam, getAllGroupsAssociatedToTeam, getGroupsByUserIdPaginated} from 'mattermost-redux/actions/groups'; import {logError} from 'mattermost-redux/actions/errors'; -import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isCustomGroupsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; @@ -14,6 +15,7 @@ import {isSuccess} from 'types/actions'; import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; import {addUserToTeam} from 'actions/team_actions'; +import {fetchChannelsAndMembers} from 'actions/channel_actions'; import LocalStorageStore from 'stores/local_storage_store'; @@ -28,6 +30,19 @@ export function initializeTeam(team: Team): ActionFunc { const currentUser = getCurrentUser(state); LocalStorageStore.setPreviousTeamId(currentUser.id, team.id); + const graphQLEnabled = isGraphQLEnabled(state); + try { + if (graphQLEnabled) { + await dispatch(fetchChannelsAndMembers(team.id)); + } else { + await dispatch(fetchMyChannelsAndMembersREST(team.id)); + } + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + dispatch(logError(error as ServerError)); + return {error: error as ServerError}; + } + dispatch(loadStatusesForChannelAndSidebar()); const license = getLicense(state); From dd2b1db420ce3f79f4a168259b865da9e953f6b0 Mon Sep 17 00:00:00 2001 From: Konstantinos Pittas Date: Thu, 20 Apr 2023 14:05:54 +0300 Subject: [PATCH 083/113] [MM-51076] Fix dropdown in modal for adding users in a group (#22642) Co-authored-by: Mattermost Build --- .../__snapshots__/create_user_groups_modal.test.tsx.snap | 2 -- .../create_user_groups_modal/create_user_groups_modal.scss | 4 ++++ .../create_user_groups_modal/create_user_groups_modal.tsx | 4 +--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap b/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap index e7f6c4ffec..38fb416e91 100644 --- a/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap +++ b/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap @@ -65,7 +65,6 @@ exports[`component/create_user_groups_modal should match snapshot with back butt
- +
Date: Thu, 20 Apr 2023 09:52:59 -0300 Subject: [PATCH 084/113] Channels/api4 testing improvements (#22938) * api4/post_test: fix missing TearDown * api4/plugin_test: dont test timeout, saving 120s * api4/channel_test: dont try to delete town square * api4/channel_test: check public channel names deterministically * api4/file_test: fix darwin assertions on go files * api4/notify_admin_test: fix expect/actual order * api4/team_test: make TestGetAllTeams deterministic * api4/plugin_test: avoid nested test helpers * api4/post_test: avoid nested test helpers * api4/websocket_test: externalize log buffer * testlib/helper: unset common env * linting issues * simplify TestGetFileHeaders * team_test: leverage ElementsMatch --- server/channels/api4/apitestlib.go | 6 - server/channels/api4/channel_test.go | 26 ++- server/channels/api4/file_test.go | 14 +- server/channels/api4/notify_admin_test.go | 14 +- server/channels/api4/plugin_test.go | 214 +++++++++++++--------- server/channels/api4/post_test.go | 73 ++++++-- server/channels/api4/team_test.go | 12 +- server/channels/api4/websocket_test.go | 6 +- server/channels/testlib/helper.go | 5 + 9 files changed, 232 insertions(+), 138 deletions(-) diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 424445de5d..c1ef006f8d 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -1093,12 +1093,6 @@ func CheckErrorMessage(tb testing.TB, err error, message string) { require.Equalf(tb, message, appError.Message, "incorrect error message, actual: %s, expected: %s", appError.Id, message) } -func CheckStartsWith(tb testing.TB, value, prefix, message string) { - tb.Helper() - - require.True(tb, strings.HasPrefix(value, prefix), message, value) -} - // Similar to s3.New() but allows initialization of signature v2 or signature v4 client. // If signV2 input is false, function always returns signature v4. // diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 3afb21d731..20ef2cd05a 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -859,14 +859,23 @@ func TestGetPublicChannelsForTeam(t *testing.T) { require.NoError(t, err) require.Len(t, channels, 4, "wrong path") - for i, c := range channels { + var foundPublicChannel1, foundPublicChannel2 bool + for _, c := range channels { // check all channels included are open require.Equal(t, model.ChannelTypeOpen, c.Type, "should include open channel only") // only check the created 2 public channels - require.False(t, i < 2 && !(c.DisplayName == publicChannel1.DisplayName || c.DisplayName == publicChannel2.DisplayName), "should match public channel display name") + switch c.DisplayName { + case publicChannel1.DisplayName: + foundPublicChannel1 = true + case publicChannel2.DisplayName: + foundPublicChannel2 = true + } } + require.True(t, foundPublicChannel1, "failed to find publicChannel1") + require.True(t, foundPublicChannel2, "failed to find publicChannel2") + privateChannel := th.CreatePrivateChannel() channels, _, err = client.GetPublicChannelsForTeam(team.Id, 0, 100, "") require.NoError(t, err) @@ -1135,9 +1144,14 @@ func TestGetAllChannels(t *testing.T) { require.NoError(t, err) beforeCount := len(channels) - firstChannel := channels[0].Channel + deletedChannel := channels[0].Channel - _, err = client.DeleteChannel(firstChannel.Id) + // Never try to delete the default channel + if deletedChannel.Name == "town-square" { + deletedChannel = channels[1].Channel + } + + _, err = client.DeleteChannel(deletedChannel.Id) require.NoError(t, err) channels, _, err = client.GetAllChannels(0, 10000, "") @@ -1147,7 +1161,7 @@ func TestGetAllChannels(t *testing.T) { } require.NoError(t, err) require.Len(t, channels, beforeCount-1) - require.NotContains(t, ids, firstChannel.Id) + require.NotContains(t, ids, deletedChannel.Id) channels, _, err = client.GetAllChannelsIncludeDeleted(0, 10000, "") ids = []string{} @@ -1156,7 +1170,7 @@ func TestGetAllChannels(t *testing.T) { } require.NoError(t, err) require.True(t, len(channels) > beforeCount) - require.Contains(t, ids, firstChannel.Id) + require.Contains(t, ids, deletedChannel.Id) }) _, resp, err := client.GetAllChannels(0, 20, "") diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index 5e412211ae..af89e72fb0 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -15,7 +15,6 @@ import ( "net/url" "os" "path/filepath" - "runtime" "strings" "testing" "time" @@ -790,6 +789,12 @@ func TestGetFileHeaders(t *testing.T) { t.Skip("skipping because no file driver is enabled") } + CheckStartsWith := func(tb testing.TB, value, prefix, message string) { + tb.Helper() + + require.True(tb, strings.HasPrefix(value, prefix), fmt.Sprintf("%s: %s", message, value)) + } + testHeaders := func(data []byte, filename string, expectedContentType string, getInline bool, loadFile bool) func(*testing.T) { return func(t *testing.T) { if loadFile { @@ -832,11 +837,8 @@ func TestGetFileHeaders(t *testing.T) { t.Run("txt", testHeaders(data, "test.txt", "text/plain", false, false)) t.Run("html", testHeaders(data, "test.html", "text/plain", false, false)) t.Run("js", testHeaders(data, "test.js", "text/plain", false, false)) - if os.Getenv("IS_CI") == "true" { - t.Run("go", testHeaders(data, "test.go", "application/octet-stream", false, false)) - } else if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false)) - } + // *.go are categorized differently by different platforms + // t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false)) t.Run("zip", testHeaders(data, "test.zip", "application/zip", false, false)) // Not every platform can recognize these //t.Run("exe", testHeaders(data, "test.exe", "application/x-ms", false)) diff --git a/server/channels/api4/notify_admin_test.go b/server/channels/api4/notify_admin_test.go index 557ee3297c..d633421f42 100644 --- a/server/channels/api4/notify_admin_test.go +++ b/server/channels/api4/notify_admin_test.go @@ -22,7 +22,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -38,7 +38,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -53,7 +53,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -68,7 +68,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -90,7 +90,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Already notified admin") + require.Equal(t, ": Already notified admin", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) @@ -118,7 +118,7 @@ func TestTriggerNotifyAdmin(t *testing.T) { statusCode, err := th.SystemAdminClient.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) require.Error(t, err) - require.Equal(t, err.Error(), ": Internal error during cloud api request.") + require.Equal(t, ": Internal error during cloud api request.", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) @@ -132,7 +132,7 @@ func TestTriggerNotifyAdmin(t *testing.T) { statusCode, err := th.Client.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) require.Error(t, err) - require.Equal(t, err.Error(), ": You do not have the appropriate permissions.") + require.Equal(t, ": You do not have the appropriate permissions.", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) diff --git a/server/channels/api4/plugin_test.go b/server/channels/api4/plugin_test.go index 5793041423..98ff5ac3f1 100644 --- a/server/channels/api4/plugin_test.go +++ b/server/channels/api4/plugin_test.go @@ -76,24 +76,6 @@ func TestPlugin(t *testing.T) { _, err = client.RemovePlugin(manifest.Id) require.NoError(t, err) - t.Run("install plugin from URL with slow response time", func(t *testing.T) { - if testing.Short() { - t.Skip("skipping test to install plugin from a slow response server") - } - - // Install from URL - slow server to simulate longer bundle download times - slowTestServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - time.Sleep(60 * time.Second) // Wait longer than the previous default 30 seconds timeout - res.WriteHeader(http.StatusOK) - res.Write(tarData) - })) - defer func() { slowTestServer.Close() }() - - manifest, _, err = client.InstallPluginFromURL(slowTestServer.URL, true) - require.NoError(t, err) - assert.Equal(t, "testplugin", manifest.Id) - }) - th.App.Channels().RemovePlugin(manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) @@ -121,6 +103,7 @@ func TestPlugin(t *testing.T) { // Successful upload manifest, _, err = client.UploadPlugin(bytes.NewReader(tarData)) require.NoError(t, err) + assert.Equal(t, "testplugin", manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableUploads = true }) @@ -1652,6 +1635,59 @@ func TestInstallMarketplacePlugin(t *testing.T) { require.Nil(t, manifest) assert.True(t, requestHandled) }, "verify EnterprisePlugins is true for E20") +} + +func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) { + path, _ := fileutils.FindDir("tests") + + signatureFilename := "testplugin2.tar.gz.sig" + signatureFileReader, err := os.Open(filepath.Join(path, signatureFilename)) + require.NoError(t, err) + sigFile, err := io.ReadAll(signatureFileReader) + require.NoError(t, err) + pluginSignature := base64.StdEncoding.EncodeToString(sigFile) + + tarData, err := os.ReadFile(filepath.Join(path, "testplugin2.tar.gz")) + require.NoError(t, err) + pluginServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + res.Write(tarData) + })) + defer pluginServer.Close() + + samplePlugins := []*model.MarketplacePlugin{ + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps", + IconData: "https://example.com/icon.svg", + DownloadURL: pluginServer.URL, + Manifest: &model.Manifest{ + Id: "testplugin2", + Name: "testplugin2", + Description: "a second plugin", + Version: "1.2.2", + MinServerVersion: "", + }, + }, + InstalledVersion: "", + }, + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps", + IconData: "https://example.com/icon.svg", + DownloadURL: pluginServer.URL, + Manifest: &model.Manifest{ + Id: "testplugin2", + Name: "testplugin2", + Description: "a second plugin", + Version: "1.2.3", + MinServerVersion: "", + }, + Signature: pluginSignature, + }, + InstalledVersion: "", + }, + } t.Run("install prepackaged and remote plugins through marketplace", func(t *testing.T) { prepackagedPluginsDir := "prepackaged_plugins" @@ -1669,13 +1705,13 @@ func TestInstallMarketplacePlugin(t *testing.T) { err = testlib.CopyFile(filepath.Join(path, "testplugin.tar.gz.asc"), filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz.sig")) require.NoError(t, err) - th2 := SetupConfig(t, func(cfg *model.Config) { + th := SetupConfig(t, func(cfg *model.Config) { // Disable auto-installing prepackaged plugins *cfg.PluginSettings.AutomaticPrepackagedPlugins = false }).InitBasic() - defer th2.TearDown() + defer th.TearDown() - th2.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { pluginSignatureFile, err := os.Open(filepath.Join(path, "testplugin.tar.gz.asc")) require.NoError(t, err) pluginSignatureData, err := io.ReadAll(pluginSignatureFile) @@ -1683,7 +1719,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { key, err := os.Open(filepath.Join(path, "development-private-key.asc")) require.NoError(t, err) - appErr := th2.App.AddPublicKey("pub_key", key) + appErr := th.App.AddPublicKey("pub_key", key) require.Nil(t, appErr) testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { @@ -1698,14 +1734,14 @@ func TestInstallMarketplacePlugin(t *testing.T) { })) defer testServer.Close() - th2.App.UpdateConfig(func(cfg *model.Config) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableMarketplace = true *cfg.PluginSettings.EnableRemoteMarketplace = false *cfg.PluginSettings.MarketplaceURL = testServer.URL *cfg.PluginSettings.AllowInsecureDownloadURL = false }) - env := th2.App.GetPluginsEnvironment() + env := th.App.GetPluginsEnvironment() pluginsResp, _, err := client.GetPlugins() require.NoError(t, err) @@ -1751,7 +1787,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { require.Nil(t, manifest) // Enable remote marketplace - th2.App.UpdateConfig(func(cfg *model.Config) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableMarketplace = true *cfg.PluginSettings.EnableRemoteMarketplace = true *cfg.PluginSettings.MarketplaceURL = testServer.URL @@ -1784,12 +1820,12 @@ func TestInstallMarketplacePlugin(t *testing.T) { _, err = client.RemovePlugin(manifest2.Id) require.NoError(t, err) - appErr = th2.App.DeletePublicKey("pub_key") + appErr = th.App.DeletePublicKey("pub_key") require.Nil(t, appErr) }) }) - th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + t.Run("missing prepackaged and remote plugin signatures", func(t *testing.T) { prepackagedPluginsDir := "prepackaged_plugins" os.RemoveAll(prepackagedPluginsDir) @@ -1809,70 +1845,72 @@ func TestInstallMarketplacePlugin(t *testing.T) { }).InitBasic() defer th.TearDown() - key, err := os.Open(filepath.Join(path, "development-private-key.asc")) - require.NoError(t, err) - appErr := th.App.AddPublicKey("pub_key", key) - require.Nil(t, appErr) - - testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - serverVersion := req.URL.Query().Get("server_version") - require.NotEmpty(t, serverVersion) - require.Equal(t, model.CurrentVersion, serverVersion) - - mPlugins := []*model.MarketplacePlugin{samplePlugins[0]} - require.Empty(t, mPlugins[0].Signature) - res.WriteHeader(http.StatusOK) - var out []byte - out, err = json.Marshal(mPlugins) + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + key, err := os.Open(filepath.Join(path, "development-private-key.asc")) require.NoError(t, err) - res.Write(out) - })) - defer testServer.Close() + appErr := th.App.AddPublicKey("pub_key", key) + require.Nil(t, appErr) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.EnableMarketplace = true - *cfg.PluginSettings.EnableRemoteMarketplace = true - *cfg.PluginSettings.MarketplaceURL = testServer.URL - *cfg.PluginSettings.AllowInsecureDownloadURL = true + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + serverVersion := req.URL.Query().Get("server_version") + require.NotEmpty(t, serverVersion) + require.Equal(t, model.CurrentVersion, serverVersion) + + mPlugins := []*model.MarketplacePlugin{samplePlugins[0]} + require.Empty(t, mPlugins[0].Signature) + res.WriteHeader(http.StatusOK) + var out []byte + out, err = json.Marshal(mPlugins) + require.NoError(t, err) + res.Write(out) + })) + defer testServer.Close() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.EnableRemoteMarketplace = true + *cfg.PluginSettings.MarketplaceURL = testServer.URL + *cfg.PluginSettings.AllowInsecureDownloadURL = true + }) + + env := th.App.GetPluginsEnvironment() + plugins := env.PrepackagedPlugins() + require.Len(t, plugins, 1) + require.Equal(t, "testplugin", plugins[0].Manifest.Id) + require.Empty(t, plugins[0].Signature) + + pluginsResp, _, err := client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} + manifest, resp, err := client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) + + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} + manifest, resp, err = client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) + + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + // Clean up + appErr = th.App.DeletePublicKey("pub_key") + require.Nil(t, appErr) }) - - env := th.App.GetPluginsEnvironment() - plugins := env.PrepackagedPlugins() - require.Len(t, plugins, 1) - require.Equal(t, "testplugin", plugins[0].Manifest.Id) - require.Empty(t, plugins[0].Signature) - - pluginsResp, _, err := client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} - manifest, resp, err := client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) - - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} - manifest, resp, err = client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) - - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - // Clean up - appErr = th.App.DeletePublicKey("pub_key") - require.Nil(t, appErr) - }, "missing prepackaged and remote plugin signatures") + }) } func findClusterMessages(event model.ClusterEvent, msgs []*model.ClusterMessage) []*model.ClusterMessage { diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index e3d3863049..8ab88fefff 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -452,32 +452,68 @@ func testCreatePostWithOutgoingHook( } func TestCreatePostWithOutgoingHook_form_urlencoded(t *testing.T) { - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + }) } func TestCreatePostWithOutgoingHook_json(t *testing.T) { - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + }) } // hooks created before we added the ContentType field should be considered as // application/x-www-form-urlencoded func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) { - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + }) } func TestCreatePostPublic(t *testing.T) { @@ -3199,6 +3235,7 @@ func TestGetEditHistoryForPost(t *testing.T) { func TestCreatePostNotificationsWithCRT(t *testing.T) { th := Setup(t).InitBasic() + defer th.TearDown() rpost := th.CreatePost() th.App.UpdateConfig(func(cfg *model.Config) { diff --git a/server/channels/api4/team_test.go b/server/channels/api4/team_test.go index ee4a4ca74d..e9ae1bea28 100644 --- a/server/channels/api4/team_test.go +++ b/server/channels/api4/team_test.go @@ -1173,11 +1173,10 @@ func TestGetAllTeams(t *testing.T) { } var teams []*model.Team - var count int64 var resp *model.Response var err2 error if tc.WithCount { - teams, count, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage) + teams, _, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage) } else { teams, resp, err2 = client.GetAllTeams("", tc.Page, tc.PerPage) } @@ -1187,11 +1186,12 @@ func TestGetAllTeams(t *testing.T) { return } require.NoError(t, err2) - require.Equal(t, len(tc.ExpectedTeams), len(teams)) - for idx, team := range teams { - assert.Equal(t, tc.ExpectedTeams[idx], team.Id) + + actualTeamIds := make([]string, 0, len(tc.ExpectedTeams)) + for _, team := range teams { + actualTeamIds = append(actualTeamIds, team.Id) } - require.Equal(t, tc.ExpectedCount, count) + require.ElementsMatch(t, tc.ExpectedTeams, actualTeamIds) }) } diff --git a/server/channels/api4/websocket_test.go b/server/channels/api4/websocket_test.go index 87eef66dbe..b7127814bc 100644 --- a/server/channels/api4/websocket_test.go +++ b/server/channels/api4/websocket_test.go @@ -424,10 +424,14 @@ func TestWebSocketUpgrade(t *testing.T) { th := Setup(t) defer th.TearDown() + buffer := &mlog.Buffer{} + err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) + require.NoError(t, err) + url := fmt.Sprintf("http://localhost:%v", th.App.Srv().ListenAddr.Port) + model.APIURLSuffix + "/websocket" resp, err := http.Get(url) require.NoError(t, err) require.Equal(t, resp.StatusCode, http.StatusBadRequest) require.NoError(t, th.TestLogger.Flush()) - testlib.AssertLog(t, th.LogBuffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.") + testlib.AssertLog(t, buffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.") } diff --git a/server/channels/testlib/helper.go b/server/channels/testlib/helper.go index f6a1b22531..f4e5d2bed6 100644 --- a/server/channels/testlib/helper.go +++ b/server/channels/testlib/helper.go @@ -58,6 +58,11 @@ func NewMainHelperWithOptions(options *HelperOptions) *MainHelper { os.Unsetenv("MM_SQLSETTINGS_DATASOURCE") } + // Unset environment variables commonly set for development that interfere with tests. + os.Unsetenv("MM_SERVICESETTINGS_SITEURL") + os.Unsetenv("MM_SERVICESETTINGS_LISTENADDRESS") + os.Unsetenv("MM_SERVICESETTINGS_ENABLEDEVELOPER") + var mainHelper MainHelper flag.Parse() From 87555aa24296cfc7d80a7cc650920091a9221e72 Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Thu, 20 Apr 2023 17:04:41 +0300 Subject: [PATCH 085/113] [MM-52158] - Don't show deprecation/move to annual banners to some clients (#22963) * [MM-52158] - Don't show deprecation/move to annual banners to some clients * create const * feedback impl * update test names * update banner text --------- Co-authored-by: Mattermost Build --- server/channels/api4/cloud.go | 1 + server/model/cloud.go | 1 + .../to_yearly_nudge_banner.test.tsx | 97 +++++++++++++++++++ .../to_yearly_nudge_banner.tsx | 16 ++- webapp/channels/src/i18n/en.json | 2 +- webapp/channels/src/utils/constants.tsx | 5 + webapp/platform/types/src/cloud.ts | 1 + 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index fc1c6ce33a..1c448a0efd 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -103,6 +103,7 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { DNS: "", LastInvoice: &model.Invoice{}, DelinquentSince: subscription.DelinquentSince, + BillingType: "", } } diff --git a/server/model/cloud.go b/server/model/cloud.go index a2176f8bd9..1efad1878c 100644 --- a/server/model/cloud.go +++ b/server/model/cloud.go @@ -179,6 +179,7 @@ type Subscription struct { DelinquentSince *int64 `json:"delinquent_since"` OriginallyLicensedSeats int `json:"originally_licensed_seats"` ComplianceBlocked string `json:"compliance_blocked"` + BillingType string `json:"billing_type"` } // Subscription History model represents true up event in a yearly subscription diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx index e9061333a5..1fd7456656 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx @@ -197,6 +197,58 @@ describe('ToYearlyNudgeBannerDismissable', () => { expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); }); + + test('should NOT show when subscription has billing type of internal', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.users.profiles = { + current_user_id: {roles: 'system_admin'}, + }; + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'internal', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); + }); + + test('should NOT show when subscription has billing type of licensed', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.users.profiles = { + current_user_id: {roles: 'system_admin'}, + }; + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'licensed', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); + }); }); describe('ToYearlyNudgeBanner', () => { @@ -241,6 +293,51 @@ describe('ToYearlyNudgeBanner', () => { renderWithIntlAndStore(, state); + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); + }); + test('should NOT show when subscription has billing type of internal', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'internal', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); + }); + + test('should NOT show when subscription has billing type of licensed', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'licensed', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); }); }); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx index f2eac86e69..e5640663b0 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx @@ -11,12 +11,12 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; -import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; +import {getSubscriptionProduct as selectSubscriptionProduct, getCloudSubscription as selectCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; -import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals} from 'utils/constants'; +import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals, CloudBillingTypes} from 'utils/constants'; import {t} from 'utils/i18n'; import {GlobalState} from '@mattermost/types/store'; @@ -53,6 +53,7 @@ const ToYearlyNudgeBannerDismissable = () => { const show = snoozeInfo.show; const currentUser = useSelector(getCurrentUser); + const subscription = useSelector(selectCloudSubscription); const isAdmin = useSelector(isCurrentUserSystemAdmin); const product = useSelector(selectSubscriptionProduct); const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL; @@ -139,6 +140,10 @@ const ToYearlyNudgeBannerDismissable = () => { return null; } + if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) { + return null; + } + const message = ( { showCloseButton={daysToProMonthlyEnd > 10} onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})} modalButtonText={t('cloud_billing.nudge_to_yearly.learn_more')} - modalButtonDefaultText='Learn more' + modalButtonDefaultText='Update billing' message={message} showLinkAsButton={true} handleClose={showBanner} @@ -173,6 +178,7 @@ const ToYearlyNudgeBanner = () => { const [openSalesLink] = useOpenSalesLink(); const openPurchaseModal = useOpenCloudPurchaseModal({}); + const subscription = useSelector(selectCloudSubscription); const product = useSelector(selectSubscriptionProduct); const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL; const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH; @@ -182,6 +188,10 @@ const ToYearlyNudgeBanner = () => { return null; } + if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) { + return null; + } + const now = moment(Date.now()); const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD'); const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days'); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index c5af857223..4d1aee8c3f 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3056,7 +3056,7 @@ "cloud_billing.nudge_to_yearly.announcement_bar": "Monthly billing will be discontinued in {days} days . Switch to annual billing", "cloud_billing.nudge_to_yearly.contact_sales": "Contact sales", "cloud_billing.nudge_to_yearly.description": "Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.", - "cloud_billing.nudge_to_yearly.learn_more": "Learn more", + "cloud_billing.nudge_to_yearly.learn_more": "Update billing", "cloud_billing.nudge_to_yearly.title": "Action required: Switch to annual billing to keep your workspace.", "cloud_delinquency.banner.buttonText": "Update billing now", "cloud_delinquency.banner.end_user_notify_admin_button": "Notify admin", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 7f1dce9244..91b82df939 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -504,6 +504,11 @@ export const CloudProducts = { LEGACY: 'cloud-legacy', }; +export const CloudBillingTypes = { + INTERNAL: 'internal', + LICENSED: 'licensed', +}; + export const SelfHostedProducts = { STARTER: 'starter', PROFESSIONAL: 'professional', diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 74b7195ab2..9cdb28a01c 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -41,6 +41,7 @@ export type Subscription = { is_free_trial: string; delinquent_since?: number; compliance_blocked?: string; + billing_type?: string; } export type Product = { From b5b4749da531b518fb8bf5adb1373ab6b127d6c3 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Thu, 20 Apr 2023 08:40:09 -0600 Subject: [PATCH 086/113] [MM-51997] Fix potential errors when accessing calls store (#22960) * Fix potential errors when accessing calls store * Fix typecheck --- .../src/components/profile_popover/index.ts | 4 +- .../profile_popover/profile_popover.test.tsx | 50 +++++++++++++++++++ .../src/selectors/entities/common.ts | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/profile_popover/index.ts b/webapp/channels/src/components/profile_popover/index.ts index 0af4f863f1..86c77f0203 100644 --- a/webapp/channels/src/components/profile_popover/index.ts +++ b/webapp/channels/src/components/profile_popover/index.ts @@ -50,12 +50,12 @@ function getDefaultChannelId(state: GlobalState) { return selectedPost.exists ? selectedPost.channel_id : getCurrentChannelId(state); } -function checkUserInCall(state: GlobalState, userId: string) { +export function checkUserInCall(state: GlobalState, userId: string) { let isUserInCall = false; const calls = getCalls(state); Object.keys(calls).forEach((channelId) => { - const usersInCall = calls[channelId]; + const usersInCall = calls[channelId] || []; for (const user of usersInCall) { if (user.id === userId) { diff --git a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx index 8e3a42c8cd..75eab71c80 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx @@ -9,6 +9,7 @@ import {General} from 'mattermost-redux/constants'; import {CustomStatusDuration} from '@mattermost/types/users'; import ProfilePopover from 'components/profile_popover/profile_popover'; +import {checkUserInCall} from 'components/profile_popover'; import Pluggable from 'plugins/pluggable'; @@ -284,3 +285,52 @@ describe('components/ProfilePopover', () => { expect(wrapper).toMatchSnapshot(); }); }); + +describe('checkUserInCall', () => { + test('missing state', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': {}, + } as any, 'userA')).toBe(false); + }); + + test('call state missing', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: null, + }, + }, + } as any, 'userA')).toBe(false); + }); + + test('user not in call', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: [ + { + id: 'userB', + }, + ], + }, + }, + } as any, 'userA')).toBe(false); + }); + + test('user in call', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: [ + { + id: 'userB', + }, + { + id: 'userA', + }, + ], + }, + }, + } as any, 'userA')).toBe(true); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts index b26a42976c..df49a50724 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts @@ -74,7 +74,7 @@ export function getUsers(state: GlobalState): IDMappedObjects { export function getCalls(state: GlobalState): Record { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - return state[CALLS_PLUGIN].voiceConnectedProfiles; + return state[CALLS_PLUGIN].voiceConnectedProfiles || {}; } export function getCallsConfig(state: GlobalState): CallsConfig { From a22ae500e5410f87e51e76d80b1afa4ec4da542e Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Thu, 20 Apr 2023 20:14:30 +0530 Subject: [PATCH 087/113] playbooks/server: Avoid using main package (#23041) The main package was used unnecessarily without a main function. ```release-note NONE ``` --- server/playbooks/server/api_actions_test.go | 2 +- server/playbooks/server/api_bot_test.go | 2 +- server/playbooks/server/api_general_test.go | 2 +- server/playbooks/server/api_graphql_playbooks_test.go | 2 +- server/playbooks/server/api_graphql_runs_test.go | 2 +- server/playbooks/server/api_playbooks_test.go | 2 +- server/playbooks/server/api_runs_test.go | 2 +- server/playbooks/server/api_settings_test.go | 2 +- server/playbooks/server/api_stats_test.go | 2 +- server/playbooks/server/api_telemetry_test.go | 2 +- server/playbooks/server/main_test.go | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/server/playbooks/server/api_actions_test.go b/server/playbooks/server/api_actions_test.go index b7a4cae7c5..d91747a765 100644 --- a/server/playbooks/server/api_actions_test.go +++ b/server/playbooks/server/api_actions_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_bot_test.go b/server/playbooks/server/api_bot_test.go index f14eb984ac..9dcc2baa45 100644 --- a/server/playbooks/server/api_bot_test.go +++ b/server/playbooks/server/api_bot_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "encoding/json" diff --git a/server/playbooks/server/api_general_test.go b/server/playbooks/server/api_general_test.go index b3052eb649..5793427392 100644 --- a/server/playbooks/server/api_general_test.go +++ b/server/playbooks/server/api_general_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "net/http" diff --git a/server/playbooks/server/api_graphql_playbooks_test.go b/server/playbooks/server/api_graphql_playbooks_test.go index b44332aae6..66e67fecfe 100644 --- a/server/playbooks/server/api_graphql_playbooks_test.go +++ b/server/playbooks/server/api_graphql_playbooks_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_graphql_runs_test.go b/server/playbooks/server/api_graphql_runs_test.go index 5b84ed47a4..953ea2caff 100644 --- a/server/playbooks/server/api_graphql_runs_test.go +++ b/server/playbooks/server/api_graphql_runs_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_playbooks_test.go b/server/playbooks/server/api_playbooks_test.go index 1f20c10843..eedf819272 100644 --- a/server/playbooks/server/api_playbooks_test.go +++ b/server/playbooks/server/api_playbooks_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_runs_test.go b/server/playbooks/server/api_runs_test.go index 1c2a277a14..575152b81d 100644 --- a/server/playbooks/server/api_runs_test.go +++ b/server/playbooks/server/api_runs_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_settings_test.go b/server/playbooks/server/api_settings_test.go index 6c2db58a51..0f2eb0dd5e 100644 --- a/server/playbooks/server/api_settings_test.go +++ b/server/playbooks/server/api_settings_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_stats_test.go b/server/playbooks/server/api_stats_test.go index ca575b9245..a705342e15 100644 --- a/server/playbooks/server/api_stats_test.go +++ b/server/playbooks/server/api_stats_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/api_telemetry_test.go b/server/playbooks/server/api_telemetry_test.go index 7a3c9ab10f..3a154381e7 100644 --- a/server/playbooks/server/api_telemetry_test.go +++ b/server/playbooks/server/api_telemetry_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" diff --git a/server/playbooks/server/main_test.go b/server/playbooks/server/main_test.go index d08e028bb3..5590ea392c 100644 --- a/server/playbooks/server/main_test.go +++ b/server/playbooks/server/main_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package main +package server import ( "context" From 3ba419c841fb12cd20e6f6c411406616a591f638 Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Thu, 20 Apr 2023 13:00:36 -0300 Subject: [PATCH 088/113] preserve ClientError cause with es2022 (#22762) Building the client package with `es2022`, exposing the optional `.cause` property on Errors effectively allowing us to wrap caught errors in the client package and re-throw with the context from the request, all while preserving a useful backtrace. This change has potentially material impact to older plugins that attempt to rely on the newer package, but this should only occur at compile time since the webapp doesn't dynamically export this client package. Co-authored-by: Mattermost Build --- webapp/platform/client/src/client4.test.ts | 18 ++++++++++++++++++ webapp/platform/client/src/client4.ts | 6 +++--- webapp/platform/client/tsconfig.json | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/webapp/platform/client/src/client4.test.ts b/webapp/platform/client/src/client4.test.ts index a684b5199a..d6a4eed31d 100644 --- a/webapp/platform/client/src/client4.test.ts +++ b/webapp/platform/client/src/client4.test.ts @@ -68,6 +68,24 @@ describe('ClientError', () => { expect(copy.status_code).toEqual(error.status_code); expect(copy.url).toEqual(error.url); }); + + test('cause should be preserved when provided', () => { + const cause = new Error('the original error'); + const error = new ClientError('https://example.com', { + message: 'This is a message', + server_error_id: 'test.app_error', + status_code: 418, + url: 'https://example.com/api/v4/error', + }, cause); + + const copy = {...error}; + + expect(copy.message).toEqual(error.message); + expect(copy.server_error_id).toEqual(error.server_error_id); + expect(copy.status_code).toEqual(error.status_code); + expect(copy.url).toEqual(error.url); + expect(error.cause).toEqual(cause); + }); }); describe('trackEvent', () => { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 47e6741b0e..ba9d713775 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -4168,7 +4168,7 @@ export default class Client4 { throw new ClientError(this.getUrl(), { message: 'Received invalid response from the server.', url, - }); + }, err); } if (headers.has(HEADER_X_VERSION_ID) && !headers.get('Cache-Control')) { @@ -4311,8 +4311,8 @@ export class ClientError extends Error implements ServerError { server_error_id?: string; status_code?: number; - constructor(baseUrl: string, data: ServerError) { - super(data.message + ': ' + cleanUrlForLogging(baseUrl, data.url || '')); + constructor(baseUrl: string, data: ServerError, cause?: any) { + super(data.message + ': ' + cleanUrlForLogging(baseUrl, data.url || ''), {cause}); this.message = data.message; this.url = data.url; diff --git a/webapp/platform/client/tsconfig.json b/webapp/platform/client/tsconfig.json index 992f9814f1..2dad4b02eb 100644 --- a/webapp/platform/client/tsconfig.json +++ b/webapp/platform/client/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", - "target": "es6", + "target": "es2022", "declaration": true, "strict": true, "resolveJsonModule": true, From 87908bc5770df324a655a963746a9db17cac3b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Garc=C3=ADa=20Montoro?= Date: Thu, 20 Apr 2023 19:41:36 +0200 Subject: [PATCH 089/113] MM-51095: Foundation for ESR upgrade scripts (#22448) * Add ESR upgrade migration and CI job to verify it The script was generated as a simple concatenation of migrations in the interval [54, 101] through: files=`for i in $(seq 54 101); do ls mysql/$(printf "%06d" $i)*up.sql; done` tail -n +1 $files > ../esrupgrades/esr.5.37-7.8.mysql.up.sql The CI job runs the migration both through the server and the script, and for now uploads the dumps generated for manual inspection. An automatic check for differences is still needed. * Remove debug print in script * Fix idx_uploadsessions_type creation * Ignore tables db_lock and db_migration on dump * Split workflow in two parallel jobs * Diff dumps and upload the result * Add cleanup script * Use DELIMITER in the script to use mysql CLI This allows us to remove the complexity of using a different Go script inside a Docker image. * Standardize Roles between migrations Document and cleanup code. * Upload diff only if it is not empty * Trigger action only when related files change * Add a global timeout to the job * Generalize ESR to ESR upgrade action (#22573) * Generalize action * Use logs to ensure migrations are finished * Add migrations from 5.37 to 6.3 * Remove tables in cleanup script, not through dump * Add initial-version input to common action * Add migration from 6.3 to 7.8 * Remove action debug line * ESR Upgrade: One procedure per table in the v5.37 > v7.8 upgrade script (#22590) * Squash Users-related migrations in one query * Squash Drafts-related migrations in one query * Squash UploadSessions-related migrations in one query * Squash Threads-related migrations in one query * Squash Channels-related migrations in one query * Squash ChannelMembers-related migrations in one query * Squash Jobs-related migrations in one query * Squash Sessions-related migrations in one query * Squash Status-related migrations in one query * Squash Posts-related migrations in one query * Squash TeamMembers-related migrations in one query * Squash Schemes-related migrations in one query * Squash CommandWebhooks-related migrations in one query * Squash OAuthApps-related migrations in one query * Squash Teams-related migrations in one query * Squash Reactions-related migrations in one query * Squash PostReminders-related migrations in one query * Adapt ThreadMemberships migration to unified style * Adapt LinkMetadata migrations to unified style * Adapt GroupChannels migration to unified style * Adapt PluginKVStore migration to unified style * Adapt UserGroups migration to unified style * Adapt FileInfo migration to unified style * Adapt SidebarCategories migration to unified style * Remove blank line * Use tabs everywhere * Wrap every procedure with log statements * Remove space before parentheses in procedure call * Remove spurious extra line * Merge two equal consecutive conditionals * Avoid the double list of conditions/queries * Fix variable name * Remove outdated comment * Add a preprocess phase with corresponding scripts * Join all preprocess scripts setting ExpiresAt to 0 This preprocessing is something we should always do, no matter the input DB, so we can use a common script for all cases instead of repeating the same code in multiple files. * Add system-bot if it does not exist * Cleanup the ProductNoticeViewState table * Fix SQL * Move esrupgrades directory under server/ * Update paths in Github action * Fix trigger path for CI --- .github/workflows/esrupgrade-common.yml | 159 ++ .github/workflows/esrupgrade.yml | 33 + server/scripts/esrupgrades/README.md | 1 + .../esr.5.37-6.3.mysql.cleanup.sql | 160 ++ .../esrupgrades/esr.5.37-6.3.mysql.up.sql | 695 +++++++++ .../esr.5.37-7.8.mysql.cleanup.sql | 199 +++ .../esrupgrades/esr.5.37-7.8.mysql.up.sql | 1385 +++++++++++++++++ .../esrupgrades/esr.6.3-7.8.mysql.cleanup.sql | 168 ++ .../esrupgrades/esr.6.3-7.8.mysql.up.sql | 599 +++++++ .../esr.common.mysql.preprocess.sql | 23 + 10 files changed, 3422 insertions(+) create mode 100644 .github/workflows/esrupgrade-common.yml create mode 100644 .github/workflows/esrupgrade.yml create mode 100644 server/scripts/esrupgrades/README.md create mode 100644 server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql create mode 100644 server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql create mode 100644 server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql create mode 100644 server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql create mode 100644 server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql create mode 100644 server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql create mode 100644 server/scripts/esrupgrades/esr.common.mysql.preprocess.sql diff --git a/.github/workflows/esrupgrade-common.yml b/.github/workflows/esrupgrade-common.yml new file mode 100644 index 0000000000..b0cac7d6d2 --- /dev/null +++ b/.github/workflows/esrupgrade-common.yml @@ -0,0 +1,159 @@ +name: ESR Upgrade +on: + workflow_call: + inputs: + db-dump-url: + required: true + type: string + initial-version: + required: true + type: string + final-version: + required: true + type: string +env: + COMPOSE_PROJECT_NAME: ghactions + BUILD_IMAGE: mattermost/mattermost-enterprise-edition:${{ inputs.final-version }} + MYSQL_CONN_ARGS: -h localhost -P 3306 --protocol=tcp -ummuser -pmostest mattermost_test + DUMP_SERVER_NAME: esr.${{ inputs.initial-version }}-${{ inputs.final-version }}.dump.server.sql + DUMP_SCRIPT_NAME: esr.${{ inputs.initial-version }}-${{ inputs.final-version }}.dump.script.sql + MIGRATION_SCRIPT: esr.${{ inputs.initial-version }}-${{ inputs.final-version }}.mysql.up.sql + CLEANUP_SCRIPT: esr.${{ inputs.initial-version }}-${{ inputs.final-version }}.mysql.cleanup.sql + PREPROCESS_SCRIPT: esr.common.mysql.preprocess.sql + DIFF_NAME: esr.${{ inputs.initial-version }}-${{ inputs.final-version }}.diff +jobs: + esr-upgrade-server: + runs-on: ubuntu-latest-8-cores + timeout-minutes: 30 + steps: + - name: Checkout mattermost-server + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + - name: Run docker compose + run: | + cd server/build + docker-compose --no-ansi run --rm start_dependencies + cat ../tests/test-data.ldif | docker-compose --no-ansi exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; + docker-compose --no-ansi exec -T minio sh -c 'mkdir -p /data/mattermost-test'; + docker-compose --no-ansi ps + - name: Wait for docker compose + run: | + until docker network inspect ghactions_mm-test; do echo "Waiting for Docker Compose Network..."; sleep 1; done; + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://mysql:3306; do echo waiting for mysql; sleep 5; done;" + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://elasticsearch:9200; do echo waiting for elasticsearch; sleep 5; done;" + - name: Initialize the database with the source DB dump + run: | + curl ${{ inputs.db-dump-url }} | zcat | docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS + - name: Common preprocessing of the DB dump + run: | + cd server/scripts/esrupgrades + docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS < $PREPROCESS_SCRIPT + - name: Pull EE image + run: | + docker pull $BUILD_IMAGE + - name: Run migration through server + run: | + mkdir -p client/plugins + cd server/build + # Run the server in the background to trigger the migrations + docker run --name mmserver \ + --net ghactions_mm-test \ + --ulimit nofile=8096:8096 \ + --env-file=dotenv/test.env \ + --env MM_SQLSETTINGS_DRIVERNAME="mysql" \ + --env MM_SQLSETTINGS_DATASOURCE="mmuser:mostest@tcp(mysql:3306)/mattermost_test?charset=utf8mb4,utf8&multiStatements=true" \ + -v ~/work/mattermost-server:/mattermost-server \ + -w /mattermost-server/mattermost-server \ + $BUILD_IMAGE & + # In parallel, wait for the migrations to finish. + # To verify this, we check that the server has finished the startup job through the log line "Server is listening on" + until docker logs mmserver | grep "Server is listening on"; do\ + echo "Waiting for migrations to finish..."; \ + sleep 1; \ + done; + # Make sure to stop the server. Also, redirect output to null; + # otherwise, the name of the container gets written to the console, which is weird + docker stop mmserver > /dev/null + - name: Cleanup DB + run : | + cd server/scripts/esrupgrades + docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS < $CLEANUP_SCRIPT + - name: Dump upgraded database + run: | + # Use --skip-opt to have each INSERT into one line. + # Use --set-gtid-purged=OFF to suppress GTID-related statements. + docker exec -i ghactions_mysql_1 mysqldump \ + --skip-opt --set-gtid-purged=OFF \ + $MYSQL_CONN_ARGS > $DUMP_SERVER_NAME + - name: Cleanup dump and compress + run: | + # We skip the very last line, which simply contains the date of the dump + head -n -1 ${DUMP_SERVER_NAME} | gzip > ${DUMP_SERVER_NAME}.gz + - name: Upload dump + uses: actions/upload-artifact@v3 + with: + name: upgraded-dump-server + path: ${{ env.DUMP_SERVER_NAME }}.gz + esr-upgrade-script: + runs-on: ubuntu-latest-8-cores + timeout-minutes: 30 + steps: + - name: Checkout mattermost-server + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + - name: Run docker compose + run: | + cd server/build + docker-compose --no-ansi run --rm start_dependencies + cat ../tests/test-data.ldif | docker-compose --no-ansi exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; + docker-compose --no-ansi exec -T minio sh -c 'mkdir -p /data/mattermost-test'; + docker-compose --no-ansi ps + - name: Wait for docker compose + run: | + until docker network inspect ghactions_mm-test; do echo "Waiting for Docker Compose Network..."; sleep 1; done; + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://mysql:3306; do echo waiting for mysql; sleep 5; done;" + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://elasticsearch:9200; do echo waiting for elasticsearch; sleep 5; done;" + - name: Initialize the database with the source DB dump + run: | + curl ${{ inputs.db-dump-url }} | zcat | docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS + - name: Preprocess the DB dump + run: | + cd server/scripts/esrupgrades + docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS < $PREPROCESS_SCRIPT + - name: Run migration through script + run : | + cd server/scripts/esrupgrades + docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS < $MIGRATION_SCRIPT + - name: Cleanup DB + run : | + cd server/scripts/esrupgrades + docker exec -i ghactions_mysql_1 mysql -AN $MYSQL_CONN_ARGS < $CLEANUP_SCRIPT + - name: Dump upgraded database + run: | + docker exec -i ghactions_mysql_1 mysqldump --skip-opt --set-gtid-purged=OFF $MYSQL_CONN_ARGS > $DUMP_SCRIPT_NAME + - name: Cleanup dump and compress + run: | + # We skip the very last line, which simply contains the date of the dump + head -n -1 ${DUMP_SCRIPT_NAME} | gzip > ${DUMP_SCRIPT_NAME}.gz + - name: Upload dump + uses: actions/upload-artifact@v3 + with: + name: upgraded-dump-script + path: ${{ env.DUMP_SCRIPT_NAME }}.gz + esr-upgrade-diff: + runs-on: ubuntu-latest-8-cores + needs: + - esr-upgrade-server + - esr-upgrade-script + steps: + - name: Retrieve dumps + uses: actions/download-artifact@v3 + - name: Diff dumps + run: | + gzip -d upgraded-dump-server/${DUMP_SERVER_NAME}.gz + gzip -d upgraded-dump-script/${DUMP_SCRIPT_NAME}.gz + diff upgraded-dump-server/$DUMP_SERVER_NAME upgraded-dump-script/$DUMP_SCRIPT_NAME > $DIFF_NAME + - name: Upload diff + if: failure() # Upload the diff only if the previous step failed; i.e., if the diff is non-empty + uses: actions/upload-artifact@v3 + with: + name: dumps-diff + path: ${{ env.DIFF_NAME }} diff --git a/.github/workflows/esrupgrade.yml b/.github/workflows/esrupgrade.yml new file mode 100644 index 0000000000..71624f826a --- /dev/null +++ b/.github/workflows/esrupgrade.yml @@ -0,0 +1,33 @@ +name: ESR Upgrade +on: + pull_request: + paths: + - 'server/scripts/esrupgrades/*' + - '.github/workflows/esr*' + push: + branches: + - master + - cloud + - release-* +jobs: + esr-upgrade-5_37-7_8: + name: Run ESR upgrade script from 5.37 to 7.8 + uses: ./.github/workflows/esrupgrade-common.yml + with: + db-dump-url: https://lt-public-data.s3.amazonaws.com/47K_537_mysql_collationfixed.sql.gz + initial-version: 5.37 + final-version: 7.8 + esr-upgrade-5_37-6_3: + name: Run ESR upgrade script from 5.37 to 6.3 + uses: ./.github/workflows/esrupgrade-common.yml + with: + db-dump-url: https://lt-public-data.s3.amazonaws.com/47K_537_mysql_collationfixed.sql.gz + initial-version: 5.37 + final-version: 6.3 + esr-upgrade-6_3-7_8: + name: Run ESR upgrade script from 6.3 to 7.8 + uses: ./.github/workflows/esrupgrade-common.yml + with: + db-dump-url: https://lt-public-data.s3.amazonaws.com/47K_63_mysql.sql.gz + initial-version: 6.3 + final-version: 7.8 diff --git a/server/scripts/esrupgrades/README.md b/server/scripts/esrupgrades/README.md new file mode 100644 index 0000000000..e71dcb2487 --- /dev/null +++ b/server/scripts/esrupgrades/README.md @@ -0,0 +1 @@ +A collection of ad-hoc scripts to upgrade between ESRs. diff --git a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql new file mode 100644 index 0000000000..3a13b11f83 --- /dev/null +++ b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql @@ -0,0 +1,160 @@ +/* Product notices are controlled externally, via the mattermost/notices repository. + When there is a new notice specified there, the server may have time, right after + the migration and before it is shut down, to download it and modify the + ProductNoticeViewState table, adding a row for all users that have not seen it or + removing old notices that no longer need to be shown. This can happen in the + UpdateProductNotices function that is executed periodically to update the notices + cache. The script will never do this, so we need to remove all rows in that table + to avoid any unwanted diff. */ +DELETE FROM ProductNoticeViewState; + +/* The script does not update the Systems row that tracks the version, so it is manually updated + here so that it does not show in the diff. */ +UPDATE Systems SET Value = '6.3.0' WHERE Name = 'Version'; + +/* The script does not update the schema_migrations table, which is automatically used by the + migrate library to track the version, so we drop it altogether to avoid spurious errors in + the diff */ +DROP TABLE IF EXISTS schema_migrations; + +/* Migration 000054_create_crt_channelmembership_count.up sets + ChannelMembers.LastUpdateAt to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) + which will be different each time the migration is run. Thus, the column will always be + different when comparing the server and script migrations. To bypass this, we update all + rows in ChannelMembers so that they contain the same value for such column. */ +UPDATE ChannelMembers SET LastUpdateAt = 1; + +/* Migration 000055_create_crt_thread_count_and_unreads.up sets + ThreadMemberships.LastUpdated to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) + which will be different each time the migration is run. Thus, the column will always be + different when comparing the server and script migrations. To bypass this, we update all + rows in ThreadMemberships so that they contain the same value for such column. */ +UPDATE ThreadMemberships SET LastUpdated = 1; + +/* The security update check in the server may update the LastSecurityTime system value. To + avoid any spurious difference in the migrations, we update it to a fixed value. */ +UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; + +/* The server migration contains an in-app migration that adds new roles for Playbooks: + doPlaybooksRolesCreationMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 + The roles are the ones defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/model/role.go#L874-L929 + When this migration finishes, it also adds a new row to the Systems table with the key of the migration. + This in-app migration does not happen in the script, so we remove those rows here. */ +DELETE FROM Roles WHERE Name = 'playbook_member'; +DELETE FROM Roles WHERE Name = 'playbook_admin'; +DELETE FROM Roles WHERE Name = 'run_member'; +DELETE FROM Roles WHERE Name = 'run_admin'; +DELETE FROM Systems WHERE Name = 'PlaybookRolesCreationMigrationComplete'; + +/* The server migration contains an in-app migration that add playbooks permissions to certain roles: + getAddPlaybooksPermissions, defined in https://github.com/mattermost/mattermost-server/blob/f9b996934cabf9a8fad5901835e7e9b418917402/app/permissions_migrations.go#L918-L951 + The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add a new row to the Systems table marking the migration as complete. + This in-app migration does not happen in the script, so we remove that rows here. */ +DELETE FROM Systems WHERE Name = 'playbooks_permissions'; + +/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: + 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column + do not appear in the diff. + 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as + this logic happens all in-app after the normal DB migrations. + 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of + the different permissions each role has. This change is the reason why we need a complex procedure, which creates + a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two + rows like: + Id: 'abcd' + Permissions: 'view_team read_public_channel invite_user' + Id: 'efgh' + Permissions: 'view_team create_emojis' + then the new temporary table will contain five rows like: + Id: 'abcd' + Permissions: 'view_team' + Id: 'abcd' + Permissions: 'read_public_channel' + Id: 'abcd' + Permissions: 'invite_user' + Id: 'efgh' + Permissions: 'view_team' + Id: 'efgh' + Permissions: 'create_emojis' +*/ + +DROP PROCEDURE IF EXISTS splitPermissions; +DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; + +DROP TEMPORARY TABLE IF EXISTS temp_roles; +CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); + +DELIMITER // + +/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted + in the temporary temp_roles table along with their corresponding ID. */ +CREATE PROCEDURE splitPermissions( + IN id varchar(26), + IN permissionsString longtext +) +BEGIN + DECLARE idx INT DEFAULT 0; + SELECT TRIM(permissionsString) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + WHILE idx > 0 DO + INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); + SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + END WHILE; + INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); +END; // + +/* Main procedure that does update the Roles table */ +CREATE PROCEDURE sortAndFilterPermissionsInRoles() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE rolesId varchar(26) DEFAULT ''; + DECLARE rolesPermissions longtext DEFAULT ''; + DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ + UPDATE Roles SET UpdateAt = 1; + + /* Call splitPermissions for every row in the Roles table, thus populating the + temp_roles table. */ + OPEN cur1; + read_loop: LOOP + FETCH cur1 INTO rolesId, rolesPermissions; + IF done THEN + LEAVE read_loop; + END IF; + CALL splitPermissions(rolesId, rolesPermissions); + END LOOP; + CLOSE cur1; + + /* 2. Filter out the new permissions added by the in-app migrations */ + DELETE FROM temp_roles WHERE permission LIKE '%playbook%'; + DELETE FROM temp_roles WHERE permission LIKE 'run_create'; + DELETE FROM temp_roles WHERE permission LIKE 'run_manage_members'; + DELETE FROM temp_roles WHERE permission LIKE 'run_manage_properties'; + DELETE FROM temp_roles WHERE permission LIKE 'run_view'; + + /* Temporarily set to the maximum permitted value, since the call to group_concat + below needs a value bigger than the default */ + SET group_concat_max_len = 18446744073709551615; + + /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, + concatenated again as a space-separated string */ + UPDATE + Roles INNER JOIN ( + SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions + FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id + GROUP BY temp_roles.id + ) AS Sorted + ON Roles.Id = Sorted.Id + SET Roles.Permissions = Sorted.Permissions; + + /* Reset group_concat_max_len to its default value */ + SET group_concat_max_len = 1024; +END; // +DELIMITER ; + +CALL sortAndFilterPermissionsInRoles(); + +DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql new file mode 100644 index 0000000000..53c1c211fa --- /dev/null +++ b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql @@ -0,0 +1,695 @@ +/* ==> mysql/000054_create_crt_channelmembership_count.up.sql <== */ +/* fixCRTChannelMembershipCounts fixes the channel counts, i.e. the total message count, +total root message count, mention count, and mention count in root messages for users +who have viewed the channel after the last post in the channel */ + +DELIMITER // +CREATE PROCEDURE MigrateCRTChannelMembershipCounts () +BEGIN + IF( + SELECT + EXISTS ( + SELECT + * FROM Systems + WHERE + Name = 'CRTChannelMembershipCountsMigrationComplete') = 0) THEN + UPDATE + ChannelMembers + INNER JOIN Channels ON Channels.Id = ChannelMembers.ChannelId SET + MentionCount = 0, MentionCountRoot = 0, MsgCount = Channels.TotalMsgCount, MsgCountRoot = Channels.TotalMsgCountRoot, LastUpdateAt = ( + SELECT + (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))) + WHERE + ChannelMembers.LastViewedAt >= Channels.LastPostAt; + INSERT INTO Systems + VALUES('CRTChannelMembershipCountsMigrationComplete', 'true'); + END IF; +END// +DELIMITER ; +CALL MigrateCRTChannelMembershipCounts (); +DROP PROCEDURE IF EXISTS MigrateCRTChannelMembershipCounts; + +/* ==> mysql/000055_create_crt_thread_count_and_unreads.up.sql <== */ +/* fixCRTThreadCountsAndUnreads Marks threads as read for users where the last +reply time of the thread is earlier than the time the user viewed the channel. +Marking a thread means setting the mention count to zero and setting the +last viewed at time of the the thread as the last viewed at time +of the channel */ + +DELIMITER // +CREATE PROCEDURE MigrateCRTThreadCountsAndUnreads () +BEGIN + IF(SELECT EXISTS(SELECT * FROM Systems WHERE Name = 'CRTThreadCountsAndUnreadsMigrationComplete') = 0) THEN + UPDATE + ThreadMemberships + INNER JOIN ( + SELECT + PostId, + UserId, + ChannelMembers.LastViewedAt AS CM_LastViewedAt, + Threads.LastReplyAt + FROM + Threads + INNER JOIN ChannelMembers ON ChannelMembers.ChannelId = Threads.ChannelId + WHERE + Threads.LastReplyAt <= ChannelMembers.LastViewedAt) AS q ON ThreadMemberships.Postid = q.PostId + AND ThreadMemberships.UserId = q.UserId SET LastViewed = q.CM_LastViewedAt + 1, UnreadMentions = 0, LastUpdated = ( + SELECT + (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))); + INSERT INTO Systems + VALUES('CRTThreadCountsAndUnreadsMigrationComplete', 'true'); + END IF; +END// +DELIMITER ; +CALL MigrateCRTThreadCountsAndUnreads (); +DROP PROCEDURE IF EXISTS MigrateCRTThreadCountsAndUnreads; + +/* ==> mysql/000056_upgrade_channels_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id_display_name' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_channels_team_id_display_name ON Channels(TeamId, DisplayName);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id_type' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_channels_team_id_type ON Channels(TeamId, Type);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id' + ) > 0, + 'DROP INDEX idx_channels_team_id ON Channels;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +/* ==> mysql/000057_upgrade_command_webhooks_v6.0.up.sql <== */ + +DELIMITER // +CREATE PROCEDURE MigrateRootId_CommandWebhooks () BEGIN DECLARE ParentId_EXIST INT; +SELECT COUNT(*) +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME = 'CommandWebhooks' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'ParentId' INTO ParentId_EXIST; +IF(ParentId_EXIST > 0) THEN + UPDATE CommandWebhooks SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; +END IF; +END// +DELIMITER ; +CALL MigrateRootId_CommandWebhooks (); +DROP PROCEDURE IF EXISTS MigrateRootId_CommandWebhooks; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'CommandWebhooks' + AND table_schema = DATABASE() + AND column_name = 'ParentId' + ) > 0, + 'ALTER TABLE CommandWebhooks DROP COLUMN ParentId;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000058_upgrade_channelmembers_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'NotifyProps' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE ChannelMembers MODIFY COLUMN NotifyProps JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_user_id' + ) > 0, + 'DROP INDEX idx_channelmembers_user_id ON ChannelMembers;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_user_id_channel_id_last_viewed_at' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_channelmembers_user_id_channel_id_last_viewed_at ON ChannelMembers(UserId, ChannelId, LastViewedAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_channel_id_scheme_guest_user_id' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_channelmembers_channel_id_scheme_guest_user_id ON ChannelMembers(ChannelId, SchemeGuest, UserId);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000059_upgrade_users_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Users MODIFY COLUMN Props JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'NotifyProps' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Users MODIFY COLUMN NotifyProps JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Timezone' + AND column_default IS NOT NULL + ) > 0, + 'ALTER TABLE Users ALTER Timezone DROP DEFAULT;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Timezone' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Users MODIFY COLUMN Timezone JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND column_type != 'text' + ) > 0, + 'ALTER TABLE Users MODIFY COLUMN Roles text;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000060_upgrade_jobs_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Jobs' + AND table_schema = DATABASE() + AND column_name = 'Data' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Jobs MODIFY COLUMN Data JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + + +/* ==> mysql/000061_upgrade_link_metadata_v6.0.up.sql <== */ + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'LinkMetadata' + AND table_schema = DATABASE() + AND column_name = 'Data' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000062_upgrade_sessions_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Sessions' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Sessions MODIFY COLUMN Props JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + + +/* ==> mysql/000063_upgrade_threads_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'Participants' + AND column_type != 'JSON' + ) > 0, + 'ALTER TABLE Threads MODIFY COLUMN Participants JSON;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND index_name = 'idx_threads_channel_id_last_reply_at' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_threads_channel_id_last_reply_at ON Threads(ChannelId, LastReplyAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND index_name = 'idx_threads_channel_id' + ) > 0, + 'DROP INDEX idx_threads_channel_id ON Threads;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +/* ==> mysql/000064_upgrade_status_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Status' + AND table_schema = DATABASE() + AND index_name = 'idx_status_status_dndendtime' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_status_status_dndendtime ON Status(Status, DNDEndTime);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Status' + AND table_schema = DATABASE() + AND index_name = 'idx_status_status' + ) > 0, + 'DROP INDEX idx_status_status ON Status;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +/* ==> mysql/000065_upgrade_groupchannels_v6.0.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'GroupChannels' + AND table_schema = DATABASE() + AND index_name = 'idx_groupchannels_schemeadmin' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000066_upgrade_posts_v6.0.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateRootId_Posts () +BEGIN +DECLARE ParentId_EXIST INT; +DECLARE Alter_FileIds INT; +DECLARE Alter_Props INT; +SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME = 'Posts' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'ParentId' INTO ParentId_EXIST; +SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND column_name = 'FileIds' + AND column_type != 'text' INTO Alter_FileIds; +SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND column_type != 'JSON' INTO Alter_Props; +IF (Alter_Props OR Alter_FileIds) THEN + IF(ParentId_EXIST > 0) THEN + UPDATE Posts SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; + ALTER TABLE Posts MODIFY COLUMN FileIds text, MODIFY COLUMN Props JSON, DROP COLUMN ParentId; + ELSE + ALTER TABLE Posts MODIFY COLUMN FileIds text, MODIFY COLUMN Props JSON; + END IF; +END IF; +END// +DELIMITER ; +CALL MigrateRootId_Posts (); +DROP PROCEDURE IF EXISTS MigrateRootId_Posts; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_root_id_delete_at' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_posts_root_id_delete_at ON Posts(RootId, DeleteAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_root_id' + ) > 0, + 'DROP INDEX idx_posts_root_id ON Posts;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +/* ==> mysql/000067_upgrade_channelmembers_v6.1.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND column_type != 'text' + ) > 0, + 'ALTER TABLE ChannelMembers MODIFY COLUMN Roles text;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000068_upgrade_teammembers_v6.1.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND column_type != 'text' + ) > 0, + 'ALTER TABLE TeamMembers MODIFY COLUMN Roles text;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000069_upgrade_jobs_v6.1.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Jobs' + AND table_schema = DATABASE() + AND index_name = 'idx_jobs_status_type' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_jobs_status_type ON Jobs(Status, Type);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000070_upgrade_cte_v6.1.up.sql <== */ +DELIMITER // +CREATE PROCEDURE Migrate_LastRootPostAt () +BEGIN +DECLARE + LastRootPostAt_EXIST INT; + SELECT + COUNT(*) + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_NAME = 'Channels' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'LastRootPostAt' INTO LastRootPostAt_EXIST; + IF(LastRootPostAt_EXIST = 0) THEN + ALTER TABLE Channels ADD COLUMN LastRootPostAt bigint DEFAULT 0; + UPDATE + Channels + INNER JOIN ( + SELECT + Channels.Id channelid, + COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost + FROM + Channels + LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId + WHERE + Posts.RootId = '' + GROUP BY + Channels.Id) AS q ON q.channelid = Channels.Id SET LastRootPostAt = lastrootpost; + END IF; +END// +DELIMITER ; +CALL Migrate_LastRootPostAt (); +DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt; + +/* ==> mysql/000071_upgrade_sessions_v6.1.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Sessions' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND column_type != 'text' + ) > 0, + 'ALTER TABLE Sessions MODIFY COLUMN Roles text;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000072_upgrade_schemes_v6.3.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultPlaybookAdminRole' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT "";' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultPlaybookMemberRole' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT "";' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultRunAdminRole' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Schemes ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT "";' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultRunMemberRole' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Schemes ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT "";' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +/* ==> mysql/000073_upgrade_plugin_key_value_store_v6.3.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT Count(*) FROM Information_Schema.Columns + WHERE table_name = 'PluginKeyValueStore' + AND table_schema = DATABASE() + AND column_name = 'PKey' + AND column_type != 'varchar(150)' + ) > 0, + 'ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150);', + 'SELECT 1' +)); + +PREPARE alterTypeIfExists FROM @preparedStatement; +EXECUTE alterTypeIfExists; +DEALLOCATE PREPARE alterTypeIfExists; + +/* ==> mysql/000074_upgrade_users_v6.3.up.sql <== */ + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'AcceptedTermsOfServiceId' + ) > 0, + 'ALTER TABLE Users DROP COLUMN AcceptedTermsOfServiceId;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; diff --git a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql new file mode 100644 index 0000000000..4c23874cb1 --- /dev/null +++ b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql @@ -0,0 +1,199 @@ +/* Product notices are controlled externally, via the mattermost/notices repository. + When there is a new notice specified there, the server may have time, right after + the migration and before it is shut down, to download it and modify the + ProductNoticeViewState table, adding a row for all users that have not seen it or + removing old notices that no longer need to be shown. This can happen in the + UpdateProductNotices function that is executed periodically to update the notices + cache. The script will never do this, so we need to remove all rows in that table + to avoid any unwanted diff. */ +DELETE FROM ProductNoticeViewState; + +/* Remove migration-related tables that are only updated through the server to track which + migrations have been applied */ +DROP TABLE IF EXISTS db_lock; +DROP TABLE IF EXISTS db_migrations; + +/* Migration 000054_create_crt_channelmembership_count.up sets + ChannelMembers.LastUpdateAt to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) + which will be different each time the migration is run. Thus, the column will always be + different when comparing the server and script migrations. To bypass this, we update all + rows in ChannelMembers so that they contain the same value for such column. */ +UPDATE ChannelMembers SET LastUpdateAt = 1; + +/* Migration 000055_create_crt_thread_count_and_unreads.up sets + ThreadMemberships.LastUpdated to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) + which will be different each time the migration is run. Thus, the column will always be + different when comparing the server and script migrations. To bypass this, we update all + rows in ThreadMemberships so that they contain the same value for such column. */ +UPDATE ThreadMemberships SET LastUpdated = 1; + +/* The security update check in the server may update the LastSecurityTime system value. To + avoid any spurious difference in the migrations, we update it to a fixed value. */ +UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; + +/* The server migration may contain a row in the Systems table marking the onboarding as complete. + There are no migrations related to this, so we can simply drop it here. */ +DELETE FROM Systems WHERE Name = 'FirstAdminSetupComplete'; + +/* The server migration contains an in-app migration that adds new roles for Playbooks: + doPlaybooksRolesCreationMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 + The roles are the ones defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/model/role.go#L874-L929 + When this migration finishes, it also adds a new row to the Systems table with the key of the migration. + This in-app migration does not happen in the script, so we remove those rows here. */ +DELETE FROM Roles WHERE Name = 'playbook_member'; +DELETE FROM Roles WHERE Name = 'playbook_admin'; +DELETE FROM Roles WHERE Name = 'run_member'; +DELETE FROM Roles WHERE Name = 'run_admin'; +DELETE FROM Systems WHERE Name = 'PlaybookRolesCreationMigrationComplete'; + +/* The server migration contains two in-app migrations that add playbooks permissions to certain roles: + getAddPlaybooksPermissions and getPlaybooksPermissionsAddManageRoles, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1021-L1072 + The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add new rows to the Systems table marking the migrations as complete. + These in-app migrations do not happen in the script, so we remove those rows here. */ +DELETE FROM Systems WHERE Name = 'playbooks_manage_roles'; +DELETE FROM Systems WHERE Name = 'playbooks_permissions'; + +/* The server migration contains an in-app migration that adds boards permissions to certain roles: + getProductsBoardsPermissions, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1074-L1093 + The specific roles (sysconsole_read_product_boards and sysconsole_write_product_boards) are removed in the procedure below, + but the migrations also adds a new row to the Systems table marking the migrations as complete. + This in-app migration does not happen in the script, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'products_boards'; + +/* TODO: REVIEW STARTING HERE */ + +/* The server migration contain an in-app migration that adds Ids to the Teams whose InviteId is an empty string: + doRemainingSchemaMigrations, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L515-L540 + The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the + Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'RemainingSchemaMigrations'; + +/* The server migration contains three in-app migration that adds a new role and new permissions + related to custom groups. The migrations are: + - doCustomGroupAdminRoleCreationMigration https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 + - getAddCustomUserGroupsPermissions https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L974-L995 + - getAddCustomUserGroupsPermissionRestore https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L997-L1019 + The specific roles and permissions are removed in the procedure below, but the migrations also + adds a new row to the Roles table for the new role and new rows to the Systems table marking the + migrations as complete. + This in-app migration does not happen in the script, so we remove that row here. */ +DELETE FROM Roles WHERE Name = 'system_custom_group_admin'; +DELETE FROM Systems WHERE Name = 'CustomGroupAdminRoleCreationMigrationComplete'; +DELETE FROM Systems WHERE Name = 'custom_groups_permissions'; +DELETE FROM Systems WHERE Name = 'custom_groups_permission_restore'; + +/* The server migration contains an in-app migration that updates the config, setting ServiceSettings.PostPriority + to true, doPostPriorityConfigDefaultTrueMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L542-L560 + The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the + Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'PostPriorityConfigDefaultTrueMigrationComplete'; + +/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: + 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column + do not appear in the diff. + 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as + this logic happens all in-app after the normal DB migrations. + 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of + the different permissions each role has. This change is the reason why we need a complex procedure, which creates + a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two + rows like: + Id: 'abcd' + Permissions: 'view_team read_public_channel invite_user' + Id: 'efgh' + Permissions: 'view_team create_emojis' + then the new temporary table will contain five rows like: + Id: 'abcd' + Permissions: 'view_team' + Id: 'abcd' + Permissions: 'read_public_channel' + Id: 'abcd' + Permissions: 'invite_user' + Id: 'efgh' + Permissions: 'view_team' + Id: 'efgh' + Permissions: 'create_emojis' +*/ + +DROP PROCEDURE IF EXISTS splitPermissions; +DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; + +DROP TEMPORARY TABLE IF EXISTS temp_roles; +CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); + +DELIMITER // + +/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted + in the temporary temp_roles table along with their corresponding ID. */ +CREATE PROCEDURE splitPermissions( + IN id varchar(26), + IN permissionsString longtext +) +BEGIN + DECLARE idx INT DEFAULT 0; + SELECT TRIM(permissionsString) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + WHILE idx > 0 DO + INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); + SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + END WHILE; + INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); +END; // + +/* Main procedure that does update the Roles table */ +CREATE PROCEDURE sortAndFilterPermissionsInRoles() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE rolesId varchar(26) DEFAULT ''; + DECLARE rolesPermissions longtext DEFAULT ''; + DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ + UPDATE Roles SET UpdateAt = 1; + + /* Call splitPermissions for every row in the Roles table, thus populating the + temp_roles table. */ + OPEN cur1; + read_loop: LOOP + FETCH cur1 INTO rolesId, rolesPermissions; + IF done THEN + LEAVE read_loop; + END IF; + CALL splitPermissions(rolesId, rolesPermissions); + END LOOP; + CLOSE cur1; + + /* 2. Filter out the new permissions added by the in-app migrations */ + DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_read_products_boards'; + DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_write_products_boards'; + DELETE FROM temp_roles WHERE permission LIKE '%playbook%'; + DELETE FROM temp_roles WHERE permission LIKE 'run_create'; + DELETE FROM temp_roles WHERE permission LIKE 'run_manage_members'; + DELETE FROM temp_roles WHERE permission LIKE 'run_manage_properties'; + DELETE FROM temp_roles WHERE permission LIKE 'run_view'; + DELETE FROM temp_roles WHERE permission LIKE '%custom_group%'; + + /* Temporarily set to the maximum permitted value, since the call to group_concat + below needs a value bigger than the default */ + SET group_concat_max_len = 18446744073709551615; + + /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, + concatenated again as a space-separated string */ + UPDATE + Roles INNER JOIN ( + SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions + FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id + GROUP BY temp_roles.id + ) AS Sorted + ON Roles.Id = Sorted.Id + SET Roles.Permissions = Sorted.Permissions; + + /* Reset group_concat_max_len to its default value */ + SET group_concat_max_len = 1024; +END; // +DELIMITER ; + +CALL sortAndFilterPermissionsInRoles(); + +DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql new file mode 100644 index 0000000000..63e5899860 --- /dev/null +++ b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql @@ -0,0 +1,1385 @@ +/* ==> mysql/000041_create_upload_sessions.up.sql <== */ +/* Release 5.37 was meant to contain the index idx_uploadsessions_type, but a bug prevented that. + This part of the migration #41 adds such index */ +/* ==> mysql/000075_alter_upload_sessions_index.up.sql <== */ +/* ==> mysql/000090_create_enums.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateUploadSessions () +BEGIN + -- 'CREATE INDEX idx_uploadsessions_type ON UploadSessions(Type);' + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + -- 'DROP INDEX idx_uploadsessions_user_id ON UploadSessions; CREATE INDEX idx_uploadsessions_user_id ON UploadSessions(UserId);' + DECLARE AlterIndex BOOLEAN; + DECLARE AlterIndexQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE UploadSessions MODIFY COLUMN Type ENUM("attachment", "import");' + DECLARE AlterColumn BOOLEAN; + DECLARE AlterColumnQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'UploadSessions' + AND table_schema = DATABASE() + AND index_name = 'idx_uploadsessions_type' + INTO CreateIndex; + + SELECT IFNULL(GROUP_CONCAT(column_name ORDER BY seq_in_index), '') = 'Type' FROM information_schema.statistics + WHERE table_name = 'UploadSessions' + AND table_schema = DATABASE() + AND index_name = 'idx_uploadsessions_user_id' + GROUP BY index_name + INTO AlterIndex; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'UploadSessions' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND REPLACE(LOWER(column_type), '"', "'") != "enum('attachment','import')" + INTO AlterColumn; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_uploadsessions_type (Type)'; + END IF; + + IF AlterIndex THEN + SET AlterIndexQuery = 'DROP INDEX idx_uploadsessions_user_id, ADD INDEX idx_uploadsessions_user_id (UserId)'; + END IF; + + IF AlterColumn THEN + SET AlterColumnQuery = 'MODIFY COLUMN Type ENUM("attachment", "import")'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', CreateIndexQuery, AlterIndexQuery, AlterColumnQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE UploadSessions ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateUploadSessions procedure starting.') AS DEBUG; +CALL MigrateUploadSessions(); +SELECT CONCAT('-- ', NOW(), ' MigrateUploadSessions procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateUploadSessions; + +/* ==> mysql/000055_create_crt_thread_count_and_unreads.up.sql <== */ +/* fixCRTThreadCountsAndUnreads Marks threads as read for users where the last +reply time of the thread is earlier than the time the user viewed the channel. +Marking a thread means setting the mention count to zero and setting the +last viewed at time of the the thread as the last viewed at time +of the channel */ +DELIMITER // +CREATE PROCEDURE MigrateThreadMemberships () +BEGIN + -- UPDATE ThreadMemberships SET LastViewed = ..., UnreadMentions = ..., LastUpdated = ... + DECLARE UpdateThreadMemberships BOOLEAN; + DECLARE UpdateThreadMembershipsQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM Systems + WHERE Name = 'CRTThreadCountsAndUnreadsMigrationComplete' + INTO UpdateThreadMemberships; + + IF UpdateThreadMemberships THEN + UPDATE ThreadMemberships INNER JOIN ( + SELECT PostId, UserId, ChannelMembers.LastViewedAt AS CM_LastViewedAt, Threads.LastReplyAt + FROM Threads INNER JOIN ChannelMembers ON ChannelMembers.ChannelId = Threads.ChannelId + WHERE Threads.LastReplyAt <= ChannelMembers.LastViewedAt + ) AS q ON ThreadMemberships.Postid = q.PostId AND ThreadMemberships.UserId = q.UserId + SET LastViewed = q.CM_LastViewedAt + 1, UnreadMentions = 0, LastUpdated = (SELECT (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))); + INSERT INTO Systems VALUES('CRTThreadCountsAndUnreadsMigrationComplete', 'true'); + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateThreadMemberships procedure starting.') AS DEBUG; +CALL MigrateThreadMemberships(); +SELECT CONCAT('-- ', NOW(), ' MigrateThreadMemberships procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateThreadMemberships; + +/* ==> mysql/000056_upgrade_channels_v6.0.up.sql <== */ +/* ==> mysql/000070_upgrade_cte_v6.1.up.sql <== */ +/* ==> mysql/000090_create_enums.up.sql <== */ +/* ==> mysql/000076_upgrade_lastrootpostat.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateChannels () +BEGIN + -- 'DROP INDEX idx_channels_team_id ON Channels;' + DECLARE DropIndex BOOLEAN; + DECLARE DropIndexQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_channels_team_id_display_name ON Channels(TeamId, DisplayName);' + DECLARE CreateIndexTeamDisplay BOOLEAN; + DECLARE CreateIndexTeamDisplayQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_channels_team_id_type ON Channels(TeamId, Type);' + DECLARE CreateIndexTeamType BOOLEAN; + DECLARE CreateIndexTeamTypeQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Channels ADD COLUMN LastRootPostAt bigint DEFAULT 0;'' + -- UPDATE Channels INNER JOIN ... + DECLARE AddLastRootPostAt BOOLEAN; + DECLARE AddLastRootPostAtQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Channels MODIFY COLUMN Type ENUM("D", "O", "G", "P");', + DECLARE ModifyColumn BOOLEAN; + DECLARE ModifyColumnQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Channels ALTER COLUMN LastRootPostAt SET DEFAULT 0;', + DECLARE SetDefault BOOLEAN; + DECLARE SetDefaultQuery TEXT DEFAULT NULL; + + -- 'UPDATE Channels SET LastRootPostAt = ...', + DECLARE UpdateLastRootPostAt BOOLEAN; + DECLARE UpdateLastRootPostAtQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id' + INTO DropIndex; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id_display_name' + INTO CreateIndexTeamDisplay; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND index_name = 'idx_channels_team_id_type' + INTO CreateIndexTeamType; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Channels' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'LastRootPostAt' + INTO AddLastRootPostAt; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND REPLACE(LOWER(column_type), '"', "'") != "enum('d','o','g','p')" + INTO ModifyColumn; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Channels' + AND TABLE_SCHEMA = DATABASE() + AND COLUMN_NAME = 'LastRootPostAt' + AND (COLUMN_DEFAULT IS NULL OR COLUMN_DEFAULT != 0) + INTO SetDefault; + + IF DropIndex THEN + SET DropIndexQuery = 'DROP INDEX idx_channels_team_id'; + END IF; + + IF CreateIndexTeamDisplay THEN + SET CreateIndexTeamDisplayQuery = 'ADD INDEX idx_channels_team_id_display_name (TeamId, DisplayName)'; + END IF; + + IF CreateIndexTeamType THEN + SET CreateIndexTeamTypeQuery = 'ADD INDEX idx_channels_team_id_type (TeamId, Type)'; + END IF; + + IF AddLastRootPostAt THEN + SET AddLastRootPostAtQuery = 'ADD COLUMN LastRootPostAt bigint DEFAULT 0'; + END IF; + + IF ModifyColumn THEN + SET ModifyColumnQuery = 'MODIFY COLUMN Type ENUM("D", "O", "G", "P")'; + END IF; + + IF SetDefault THEN + SET SetDefaultQuery = 'ALTER COLUMN LastRootPostAt SET DEFAULT 0'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', DropIndexQuery, CreateIndexTeamDisplayQuery, CreateIndexTeamTypeQuery, AddLastRootPostAtQuery, ModifyColumnQuery, SetDefaultQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Channels ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + IF AddLastRootPostAt THEN + UPDATE Channels INNER JOIN ( + SELECT Channels.Id channelid, COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost + FROM Channels LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId + WHERE Posts.RootId = '' GROUP BY Channels.Id + ) AS q ON q.channelid = Channels.Id + SET LastRootPostAt = lastrootpost; + END IF; + + -- Cover the case where LastRootPostAt was already present and there are rows with it set to NULL + IF (SELECT COUNT(*) FROM Channels WHERE LastRootPostAt IS NULL) THEN + -- fixes migrate cte and sets the LastRootPostAt for channels that don't have it set + UPDATE Channels INNER JOIN ( + SELECT Channels.Id channelid, COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost + FROM Channels LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId + WHERE Posts.RootId = '' + GROUP BY Channels.Id + ) AS q ON q.channelid = Channels.Id + SET LastRootPostAt = lastrootpost + WHERE LastRootPostAt IS NULL; + -- sets LastRootPostAt to 0, for channels with no posts + UPDATE Channels SET LastRootPostAt=0 WHERE LastRootPostAt IS NULL; + END IF; + +END// +DELIMITER ; + +SELECT CONCAT('-- ', NOW(), ' MigrateChannels procedure starting.') AS DEBUG; +CALL MigrateChannels(); +SELECT CONCAT('-- ', NOW(), ' MigrateChannels procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateChannels; + +/* ==> mysql/000057_upgrade_command_webhooks_v6.0.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateCommandWebhooks () +BEGIN + DECLARE DropParentId BOOLEAN; + + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'CommandWebhooks' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'ParentId' + INTO DropParentId; + + IF DropParentId THEN + UPDATE CommandWebhooks SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; + ALTER TABLE CommandWebhooks DROP COLUMN ParentId; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateCommandWebhooks procedure starting.') AS DEBUG; +CALL MigrateCommandWebhooks(); +SELECT CONCAT('-- ', NOW(), ' MigrateCommandWebhooks procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateCommandWebhooks; + +/* ==> mysql/000054_create_crt_channelmembership_count.up.sql <== */ +/* ==> mysql/000058_upgrade_channelmembers_v6.0.up.sql <== */ +/* ==> mysql/000067_upgrade_channelmembers_v6.1.up.sql <== */ +/* ==> mysql/000097_create_posts_priority.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateChannelMembers () +BEGIN + -- 'ALTER TABLE ChannelMembers MODIFY COLUMN NotifyProps JSON;', + DECLARE ModifyNotifyProps BOOLEAN; + DECLARE ModifyNotifyPropsQuery TEXT DEFAULT NULL; + + -- 'DROP INDEX idx_channelmembers_user_id ON ChannelMembers;', + DECLARE DropIndex BOOLEAN; + DECLARE DropIndexQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_channelmembers_user_id_channel_id_last_viewed_at ON ChannelMembers(UserId, ChannelId, LastViewedAt);' + DECLARE CreateIndexLastViewedAt BOOLEAN; + DECLARE CreateIndexLastViewedAtQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_channelmembers_channel_id_scheme_guest_user_id ON ChannelMembers(ChannelId, SchemeGuest, UserId);' + DECLARE CreateIndexSchemeGuest BOOLEAN; + DECLARE CreateIndexSchemeGuestQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE ChannelMembers MODIFY COLUMN Roles text;', + DECLARE ModifyRoles BOOLEAN; + DECLARE ModifyRolesQuery TEXT DEFAULT NOT NULL; + + -- 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', + DECLARE AddUrgentMentionCount BOOLEAN; + DECLARE AddUrgentMentionCountQuery TEXT DEFAULT NOT NULL; + + DECLARE MigrateMemberships BOOLEAN; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'NotifyProps' + AND LOWER(column_type) != 'json' + INTO ModifyNotifyProps; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_user_id' + INTO DropIndex; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_user_id_channel_id_last_viewed_at' + INTO CreateIndexLastViewedAt; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_channelmembers_channel_id_scheme_guest_user_id' + INTO CreateIndexSchemeGuest; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND LOWER(column_type) != 'text' + INTO ModifyRoles; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + INTO AddUrgentMentionCount; + + SELECT COUNT(*) = 0 FROM Systems + WHERE Name = 'CRTChannelMembershipCountsMigrationComplete' + INTO MigrateMemberships; + + IF ModifyNotifyProps THEN + SET ModifyNotifyPropsQuery = 'MODIFY COLUMN NotifyProps JSON'; + END IF; + + IF DropIndex THEN + SET DropIndexQuery = 'DROP INDEX idx_channelmembers_user_id'; + END IF; + + IF CreateIndexLastViewedAt THEN + SET CreateIndexLastViewedAtQuery = 'ADD INDEX idx_channelmembers_user_id_channel_id_last_viewed_at (UserId, ChannelId, LastViewedAt)'; + END IF; + + IF CreateIndexSchemeGuest THEN + SET CreateIndexSchemeGuestQuery = 'ADD INDEX idx_channelmembers_channel_id_scheme_guest_user_id (ChannelId, SchemeGuest, UserId)'; + END IF; + + IF ModifyRoles THEN + SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; + END IF; + + IF AddUrgentMentionCount THEN + SET AddUrgentMentionCountQuery = 'ADD COLUMN UrgentMentionCount bigint(20)'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ModifyNotifyPropsQuery, DropIndexQuery, CreateIndexLastViewedAtQuery, CreateIndexSchemeGuestQuery, ModifyRolesQuery, AddUrgentMentionCountQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE ChannelMembers ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + IF MigrateMemberships THEN + UPDATE ChannelMembers INNER JOIN Channels ON Channels.Id = ChannelMembers.ChannelId + SET MentionCount = 0, MentionCountRoot = 0, MsgCount = Channels.TotalMsgCount, MsgCountRoot = Channels.TotalMsgCountRoot, LastUpdateAt = (SELECT (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))) + WHERE ChannelMembers.LastViewedAt >= Channels.LastPostAt; + INSERT INTO Systems VALUES('CRTChannelMembershipCountsMigrationComplete', 'true'); + END IF; + +END// +DELIMITER ; + +SELECT CONCAT('-- ', NOW(), ' MigrateChannelMembers procedure starting.') AS DEBUG; +CALL MigrateChannelMembers(); +SELECT CONCAT('-- ', NOW(), ' MigrateChannelMembers procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateChannelMembers; + +/* ==> mysql/000059_upgrade_users_v6.0.up.sql <== */ +/* ==> mysql/000074_upgrade_users_v6.3.up.sql <== */ +/* ==> mysql/000077_upgrade_users_v6.5.up.sql <== */ +/* ==> mysql/000088_remaining_migrations.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateUsers () +BEGIN + -- 'ALTER TABLE Users MODIFY COLUMN Props JSON;', + DECLARE ChangeProps BOOLEAN; + DECLARE ChangePropsQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users MODIFY COLUMN NotifyProps JSON;', + DECLARE ChangeNotifyProps BOOLEAN; + DECLARE ChangeNotifyPropsQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users ALTER Timezone DROP DEFAULT;', + DECLARE DropTimezoneDefault BOOLEAN; + DECLARE DropTimezoneDefaultQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users MODIFY COLUMN Timezone JSON;', + DECLARE ChangeTimezone BOOLEAN; + DECLARE ChangeTimezoneQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users MODIFY COLUMN Roles text;', + DECLARE ChangeRoles BOOLEAN; + DECLARE ChangeRolesQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users DROP COLUMN AcceptedTermsOfServiceId;', + DECLARE DropTermsOfService BOOLEAN; + DECLARE DropTermsOfServiceQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users DROP COLUMN AcceptedServiceTermsId;', + DECLARE DropServiceTerms BOOLEAN; + DECLARE DropServiceTermsQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Users DROP COLUMN ThemeProps', + DECLARE DropThemeProps BOOLEAN; + DECLARE DropThemePropsQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND LOWER(column_type) != 'json' + INTO ChangeProps; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'NotifyProps' + AND LOWER(column_type) != 'json' + INTO ChangeNotifyProps; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND column_default IS NOT NULL + INTO DropTimezoneDefault; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Timezone' + AND LOWER(column_type) != 'json' + INTO ChangeTimezone; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND LOWER(column_type) != 'text' + INTO ChangeRoles; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'AcceptedTermsOfServiceId' + INTO DropTermsOfService; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'AcceptedServiceTermsId' + INTO DropServiceTerms; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'ThemeProps' + INTO DropThemeProps; + + IF ChangeProps THEN + SET ChangePropsQuery = 'MODIFY COLUMN Props JSON'; + END IF; + + IF ChangeNotifyProps THEN + SET ChangeNotifyPropsQuery = 'MODIFY COLUMN NotifyProps JSON'; + END IF; + + IF DropTimezoneDefault THEN + SET DropTimezoneDefaultQuery = 'ALTER Timezone DROP DEFAULT'; + END IF; + + IF ChangeTimezone THEN + SET ChangeTimezoneQuery = 'MODIFY COLUMN Timezone JSON'; + END IF; + + IF ChangeRoles THEN + SET ChangeRolesQuery = 'MODIFY COLUMN Roles text'; + END IF; + + IF DropTermsOfService THEN + SET DropTermsOfServiceQuery = 'DROP COLUMN AcceptedTermsOfServiceId'; + END IF; + + IF DropServiceTerms THEN + SET DropServiceTermsQuery = 'DROP COLUMN AcceptedServiceTermsId'; + END IF; + + IF DropThemeProps THEN + INSERT INTO Preferences(UserId, Category, Name, Value) SELECT Id, '', '', ThemeProps FROM Users WHERE Users.ThemeProps != 'null'; + SET DropThemePropsQuery = 'DROP COLUMN ThemeProps'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ChangePropsQuery, ChangeNotifyPropsQuery, DropTimezoneDefaultQuery, ChangeTimezoneQuery, ChangeRolesQuery, DropTermsOfServiceQuery, DropServiceTermsQuery, DropThemePropsQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Users ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateUsers procedure starting.') AS DEBUG; +CALL MigrateUsers(); +SELECT CONCAT('-- ', NOW(), ' MigrateUsers procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateUsers; + +/* ==> mysql/000060_upgrade_jobs_v6.0.up.sql <== */ +/* ==> mysql/000069_upgrade_jobs_v6.1.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateJobs () +BEGIN + -- 'ALTER TABLE Jobs MODIFY COLUMN Data JSON;', + DECLARE ModifyData BOOLEAN; + DECLARE ModifyDataQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_jobs_status_type ON Jobs(Status, Type);' + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Jobs' + AND table_schema = DATABASE() + AND column_name = 'Data' + AND LOWER(column_type) != 'JSON' + INTO ModifyData; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Jobs' + AND table_schema = DATABASE() + AND index_name = 'idx_jobs_status_type' + INTO CreateIndex; + + IF ModifyData THEN + SET ModifyDataQuery = 'MODIFY COLUMN Data JSON'; + END IF; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_jobs_status_type (Status, Type)'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ModifyDataQuery, CreateIndexQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Jobs ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateJobs procedure starting.') AS DEBUG; +CALL MigrateJobs(); +SELECT CONCAT('-- ', NOW(), ' MigrateJobs procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateJobs; + +/* ==> mysql/000061_upgrade_link_metadata_v6.0.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateLinkMetadata () +BEGIN + -- ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON; + DECLARE ModifyData BOOLEAN; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'LinkMetadata' + AND table_schema = DATABASE() + AND column_name = 'Data' + AND LOWER(column_type) != 'JSON' + INTO ModifyData; + + IF ModifyData THEN + ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateLinkMetadata procedure starting.') AS DEBUG; +CALL MigrateLinkMetadata(); +SELECT CONCAT('-- ', NOW(), ' MigrateLinkMetadata procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateLinkMetadata; + +/* ==> mysql/000062_upgrade_sessions_v6.0.up.sql <== */ +/* ==> mysql/000071_upgrade_sessions_v6.1.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateSessions () +BEGIN + -- 'ALTER TABLE Sessions MODIFY COLUMN Props JSON;', + DECLARE ModifyProps BOOLEAN; + DECLARE ModifyPropsQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Sessions MODIFY COLUMN Roles text;', + DECLARE ModifyRoles BOOLEAN; + DECLARE ModifyRolesQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Sessions' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND LOWER(column_type) != 'json' + INTO ModifyProps; + + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Sessions' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND LOWER(column_type) != 'text' + INTO ModifyRoles; + + IF ModifyProps THEN + SET ModifyPropsQuery = 'MODIFY COLUMN Props JSON'; + END IF; + + IF ModifyRoles THEN + SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ModifyPropsQuery, ModifyRolesQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Sessions ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateSessions procedure starting.') AS DEBUG; +CALL MigrateSessions(); +SELECT CONCAT('-- ', NOW(), ' MigrateSessions procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateSessions; + +/* ==> mysql/000063_upgrade_threads_v6.0.up.sql <== */ +/* ==> mysql/000083_threads_threaddeleteat.up.sql <== */ +/* ==> mysql/000096_threads_threadteamid.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateThreads () +BEGIN + -- 'ALTER TABLE Threads MODIFY COLUMN Participants JSON;' + DECLARE ChangeParticipants BOOLEAN; + DECLARE ChangeParticipantsQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Threads DROP COLUMN DeleteAt;' + DECLARE DropDeleteAt BOOLEAN; + DECLARE DropDeleteAtQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Threads ADD COLUMN ThreadDeleteAt bigint(20);' + DECLARE CreateThreadDeleteAt BOOLEAN; + DECLARE CreateThreadDeleteAtQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Threads DROP COLUMN TeamId;' + DECLARE DropTeamId BOOLEAN; + DECLARE DropTeamIdQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Threads ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL;' + DECLARE CreateThreadTeamId BOOLEAN; + DECLARE CreateThreadTeamIdQuery TEXT DEFAULT NULL; + + -- CREATE INDEX idx_threads_channel_id_last_reply_at ON Threads(ChannelId, LastReplyAt); + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + -- DROP INDEX idx_threads_channel_id ON Threads; + DECLARE DropIndex BOOLEAN; + DECLARE DropIndexQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'Participants' + AND LOWER(column_type) != 'json' + INTO ChangeParticipants; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'DeleteAt' + INTO DropDeleteAt; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'ThreadDeleteAt' + INTO CreateThreadDeleteAt; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'TeamId' + INTO DropTeamId; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'ThreadTeamId' + INTO CreateThreadTeamId; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND index_name = 'idx_threads_channel_id_last_reply_at' + INTO CreateIndex; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND index_name = 'idx_threads_channel_id' + INTO DropIndex; + + IF ChangeParticipants THEN + SET ChangeParticipantsQuery = 'MODIFY COLUMN Participants JSON'; + END IF; + + IF DropDeleteAt THEN + SET DropDeleteAtQuery = 'DROP COLUMN DeleteAt'; + END IF; + + IF CreateThreadDeleteAt THEN + SET CreateThreadDeleteAtQuery = 'ADD COLUMN ThreadDeleteAt bigint(20)'; + END IF; + + IF DropTeamId THEN + SET DropTeamIdQuery = 'DROP COLUMN TeamId'; + END IF; + + IF CreateThreadTeamId THEN + SET CreateThreadTeamIdQuery = 'ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL'; + END IF; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_threads_channel_id_last_reply_at (ChannelId, LastReplyAt)'; + END IF; + + IF DropIndex THEN + SET DropIndexQuery = 'DROP INDEX idx_threads_channel_id'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ChangeParticipantsQuery, DropDeleteAtQuery, CreateThreadDeleteAtQuery, DropTeamIdQuery, CreateThreadTeamIdQuery, CreateIndexQuery, DropIndexQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Threads ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + UPDATE Threads, Posts + SET Threads.ThreadDeleteAt = Posts.DeleteAt + WHERE Posts.Id = Threads.PostId + AND Threads.ThreadDeleteAt IS NULL; + + UPDATE Threads, Channels + SET Threads.ThreadTeamId = Channels.TeamId + WHERE Channels.Id = Threads.ChannelId + AND Threads.ThreadTeamId IS NULL; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateThreads procedure starting.') AS DEBUG; +CALL MigrateThreads(); +SELECT CONCAT('-- ', NOW(), ' MigrateThreads procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateThreads; + +/* ==> mysql/000064_upgrade_status_v6.0.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateStatus () +BEGIN + -- 'CREATE INDEX idx_status_status_dndendtime ON Status(Status, DNDEndTime);' + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + -- 'DROP INDEX idx_status_status ON Status;', + DECLARE DropIndex BOOLEAN; + DECLARE DropIndexQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Status' + AND table_schema = DATABASE() + AND index_name = 'idx_status_status_dndendtime' + INTO CreateIndex; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Status' + AND table_schema = DATABASE() + AND index_name = 'idx_status_status' + INTO DropIndex; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_status_status_dndendtime (Status, DNDEndTime)'; + END IF; + + IF DropIndex THEN + SET DropIndexQuery = 'DROP INDEX idx_status_status'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', CreateIndexQuery, DropIndexQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Status ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateStatus procedure starting.') AS DEBUG; +CALL MigrateStatus (); +SELECT CONCAT('-- ', NOW(), ' MigrateStatus procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateStatus; + +/* ==> mysql/000065_upgrade_groupchannels_v6.0.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateGroupChannels () +BEGIN + -- 'CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin);' + DECLARE CreateIndex BOOLEAN; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'GroupChannels' + AND table_schema = DATABASE() + AND index_name = 'idx_groupchannels_schemeadmin' + INTO CreateIndex; + + IF CreateIndex THEN + CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin); + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateGroupChannels procedure starting.') AS DEBUG; +CALL MigrateGroupChannels (); +SELECT CONCAT('-- ', NOW(), ' MigrateGroupChannels procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateGroupChannels; + +/* ==> mysql/000066_upgrade_posts_v6.0.up.sql <== */ +/* ==> mysql/000080_posts_createat_id.up.sql <== */ +/* ==> mysql/000095_remove_posts_parentid.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigratePosts () +BEGIN + -- DROP COLUMN ParentId + DECLARE DropParentId BOOLEAN; + DECLARE DropParentIdQuery TEXT DEFAULT NULL; + + -- MODIFY COLUMN FileIds + DECLARE ModifyFileIds BOOLEAN; + DECLARE ModifyFileIdsQuery TEXT DEFAULT NULL; + + -- MODIFY COLUMN Props + DECLARE ModifyProps BOOLEAN; + DECLARE ModifyPropsQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_posts_root_id_delete_at ON Posts(RootId, DeleteAt);' + DECLARE CreateIndexRootId BOOLEAN; + DECLARE CreateIndexRootIdQuery TEXT DEFAULT NULL; + + -- 'DROP INDEX idx_posts_root_id ON Posts;', + DECLARE DropIndex BOOLEAN; + DECLARE DropIndexQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_posts_create_at_id on Posts(CreateAt, Id) LOCK=NONE;' + DECLARE CreateIndexCreateAt BOOLEAN; + DECLARE CreateIndexCreateAtQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Posts' + AND table_schema = DATABASE() + AND COLUMN_NAME = 'ParentId' + INTO DropParentId; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND column_name = 'FileIds' + AND LOWER(column_type) != 'text' + INTO ModifyFileIds; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND column_name = 'Props' + AND LOWER(column_type) != 'json' + INTO ModifyProps; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_root_id_delete_at' + INTO CreateIndexRootId; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_root_id' + INTO DropIndex; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_create_at_id' + INTO CreateIndexCreateAt; + + IF DropParentId THEN + SET DropParentIdQuery = 'DROP COLUMN ParentId'; + UPDATE Posts SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; + END IF; + + IF ModifyFileIds THEN + SET ModifyFileIdsQuery = 'MODIFY COLUMN FileIds text'; + END IF; + + IF ModifyProps THEN + SET ModifyPropsQuery = 'MODIFY COLUMN Props JSON'; + END IF; + + IF CreateIndexRootId THEN + SET CreateIndexRootIdQuery = 'ADD INDEX idx_posts_root_id_delete_at (RootId, DeleteAt)'; + END IF; + + IF DropIndex THEN + SET DropIndexQuery = 'DROP INDEX idx_posts_root_id'; + END IF; + + IF CreateIndexCreateAt THEN + SET CreateIndexCreateAtQuery = 'ADD INDEX idx_posts_create_at_id (CreateAt, Id)'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', DropParentIdQuery, ModifyFileIdsQuery, ModifyPropsQuery, CreateIndexRootIdQuery, DropIndexQuery, CreateIndexCreateAtQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Posts ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigratePosts procedure starting.') AS DEBUG; +CALL MigratePosts (); +SELECT CONCAT('-- ', NOW(), ' MigratePosts procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigratePosts; + +/* ==> mysql/000068_upgrade_teammembers_v6.1.up.sql <== */ +/* ==> mysql/000092_add_createat_to_teammembers.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateTeamMembers () +BEGIN + -- 'ALTER TABLE TeamMembers MODIFY COLUMN Roles text;', + DECLARE ModifyRoles BOOLEAN; + DECLARE ModifyRolesQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE TeamMembers ADD COLUMN CreateAt bigint DEFAULT 0;', + DECLARE AddCreateAt BOOLEAN; + DECLARE AddCreateAtQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_teammembers_createat ON TeamMembers(CreateAt);' + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND column_name = 'Roles' + AND LOWER(column_type) != 'text' + INTO ModifyRoles; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND column_name = 'CreateAt' + INTO AddCreateAt; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_teammembers_createat' + INTO CreateIndex; + + IF ModifyRoles THEN + SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; + END IF; + + IF AddCreateAt THEN + SET AddCreateAtQuery = 'ADD COLUMN CreateAt bigint DEFAULT 0'; + END IF; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_teammembers_createat (CreateAt)'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', ModifyRolesQuery, AddCreateAtQuery, CreateIndexQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE TeamMembers ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateTeamMembers procedure starting.') AS DEBUG; +CALL MigrateTeamMembers (); +SELECT CONCAT('-- ', NOW(), ' MigrateTeamMembers procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateTeamMembers; + +/* ==> mysql/000072_upgrade_schemes_v6.3.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateSchemes () +BEGIN + -- 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT "";' + DECLARE AddDefaultPlaybookAdminRole BOOLEAN; + DECLARE AddDefaultPlaybookAdminRoleQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT "";' + DECLARE AddDefaultPlaybookMemberRole BOOLEAN; + DECLARE AddDefaultPlaybookMemberRoleQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Schemes ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT "";' + DECLARE AddDefaultRunAdminRole BOOLEAN; + DECLARE AddDefaultRunAdminRoleQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Schemes ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT "";' + DECLARE AddDefaultRunMemberRole BOOLEAN; + DECLARE AddDefaultRunMemberRoleQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultPlaybookAdminRole' + INTO AddDefaultPlaybookAdminRole; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultPlaybookMemberRole' + INTO AddDefaultPlaybookMemberRole; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultRunAdminRole' + INTO AddDefaultRunAdminRole; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Schemes' + AND table_schema = DATABASE() + AND column_name = 'DefaultRunMemberRole' + INTO AddDefaultRunMemberRole; + + IF AddDefaultPlaybookAdminRole THEN + SET AddDefaultPlaybookAdminRoleQuery = 'ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT ""'; + END IF; + + IF AddDefaultPlaybookMemberRole THEN + SET AddDefaultPlaybookMemberRoleQuery = 'ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT ""'; + END IF; + + IF AddDefaultRunAdminRole THEN + SET AddDefaultRunAdminRoleQuery = 'ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT ""'; + END IF; + + IF AddDefaultRunMemberRole THEN + SET AddDefaultRunMemberRoleQuery = 'ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT ""'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', AddDefaultPlaybookAdminRoleQuery, AddDefaultPlaybookMemberRoleQuery, AddDefaultRunAdminRoleQuery, AddDefaultRunMemberRoleQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Schemes ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateSchemes procedure starting.') AS DEBUG; +CALL MigrateSchemes (); +SELECT CONCAT('-- ', NOW(), ' MigrateSchemes procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateSchemes; + +/* ==> mysql/000073_upgrade_plugin_key_value_store_v6.3.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigratePluginKeyValueStore () +BEGIN + -- 'ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150);', + DECLARE ModifyPKey BOOLEAN; + + SELECT COUNT(*) FROM Information_Schema.Columns + WHERE table_name = 'PluginKeyValueStore' + AND table_schema = DATABASE() + AND column_name = 'PKey' + AND LOWER(column_type) != 'varchar(150)' + INTO ModifyPKey; + + IF ModifyPKey THEN + ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150); + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigratePluginKeyValueStore procedure starting.') AS DEBUG; +CALL MigratePluginKeyValueStore (); +SELECT CONCAT('-- ', NOW(), ' MigratePluginKeyValueStore procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigratePluginKeyValueStore; + +/* ==> mysql/000078_create_oauth_mattermost_app_id.up.sql <== */ +/* ==> mysql/000082_upgrade_oauth_mattermost_app_id.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateOAuthApps () +BEGIN + -- 'ALTER TABLE OAuthApps ADD COLUMN MattermostAppID varchar(32);' + DECLARE AddMattermostAppID BOOLEAN; + DECLARE AddMattermostAppIDQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'OAuthApps' + AND table_schema = DATABASE() + AND column_name = 'MattermostAppID' + INTO AddMattermostAppID; + + IF AddMattermostAppID THEN + SET AddMattermostAppIDQuery = 'ADD COLUMN MattermostAppID varchar(32) NOT NULL DEFAULT ""'; + SET @query = CONCAT('ALTER TABLE OAuthApps ', CONCAT_WS(', ', AddMattermostAppIDQuery)); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + IF AddMattermostAppID THEN + UPDATE OAuthApps SET MattermostAppID = "" WHERE MattermostAppID IS NULL; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateOAuthApps procedure starting.') AS DEBUG; +CALL MigrateOAuthApps (); +SELECT CONCAT('-- ', NOW(), ' MigrateOAuthApps procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateOAuthApps; + +/* ==> mysql/000079_usergroups_displayname_index.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateUserGroups () +BEGIN + -- 'CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName);' + DECLARE CreateIndex BOOLEAN; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'UserGroups' + AND table_schema = DATABASE() + AND index_name = 'idx_usergroups_displayname' + INTO CreateIndex; + + IF CreateIndex THEN + CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName); + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateUserGroups procedure starting.') AS DEBUG; +CALL MigrateUserGroups (); +SELECT CONCAT('-- ', NOW(), ' MigrateUserGroups procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateUserGroups; + +/* ==> mysql/000081_threads_deleteat.up.sql <== */ +-- Replaced by 000083_threads_threaddeleteat.up.sql + +/* ==> mysql/000084_recent_searches.up.sql <== */ +CREATE TABLE IF NOT EXISTS RecentSearches ( + UserId CHAR(26), + SearchPointer int, + Query json, + CreateAt bigint NOT NULL, + PRIMARY KEY (UserId, SearchPointer) +); + +/* ==> mysql/000085_fileinfo_add_archived_column.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateFileInfo () +BEGIN + -- 'ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false;' + DECLARE AddArchived BOOLEAN; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'FileInfo' + AND table_schema = DATABASE() + AND column_name = 'Archived' + INTO AddArchived; + + IF AddArchived THEN + ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateFileInfo procedure starting.') AS DEBUG; +CALL MigrateFileInfo (); +SELECT CONCAT('-- ', NOW(), ' MigrateFileInfo procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateFileInfo; + +/* ==> mysql/000086_add_cloud_limits_archived.up.sql <== */ +/* ==> mysql/000090_create_enums.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateTeams () +BEGIN + -- 'ALTER TABLE Teams ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE;', + DECLARE AddCloudLimitsArchived BOOLEAN; + DECLARE AddCloudLimitsArchivedQuery TEXT DEFAULT NULL; + + -- 'ALTER TABLE Teams MODIFY COLUMN Type ENUM("I", "O");', + DECLARE ModifyType BOOLEAN; + DECLARE ModifyTypeQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Teams' + AND table_schema = DATABASE() + AND column_name = 'CloudLimitsArchived' + INTO AddCloudLimitsArchived; + + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Teams' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND REPLACE(LOWER(column_type), '"', "'") != "enum('i','o')" + INTO ModifyType; + + IF AddCloudLimitsArchived THEN + SET AddCloudLimitsArchivedQuery = 'ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE'; + END IF; + + IF ModifyType THEN + SET ModifyTypeQuery = 'MODIFY COLUMN Type ENUM("I", "O")'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', AddCloudLimitsArchivedQuery, ModifyTypeQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Teams ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateTeams procedure starting.') AS DEBUG; +CALL MigrateTeams (); +SELECT CONCAT('-- ', NOW(), ' MigrateTeams procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateTeams; + +/* ==> mysql/000087_sidebar_categories_index.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateSidebarCategories () +BEGIN + -- 'CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE;' + DECLARE CreateIndex BOOLEAN; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'SidebarCategories' + AND table_schema = DATABASE() + AND index_name = 'idx_sidebarcategories_userid_teamid' + INTO CreateIndex; + + IF CreateIndex THEN + CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE; + END IF; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateSidebarCategories procedure starting.') AS DEBUG; +CALL MigrateSidebarCategories (); +SELECT CONCAT('-- ', NOW(), ' MigrateSidebarCategories procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateSidebarCategories; + +/* ==> mysql/000088_remaining_migrations.up.sql <== */ +DROP TABLE IF EXISTS JobStatuses; +DROP TABLE IF EXISTS PasswordRecovery; + +/* ==> mysql/000089_add-channelid-to-reaction.up.sql <== */ +DELIMITER // +CREATE PROCEDURE MigrateReactions () +BEGIN + -- 'ALTER TABLE Reactions ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT "";', + DECLARE AddChannelId BOOLEAN; + DECLARE AddChannelIdQuery TEXT DEFAULT NULL; + + -- 'CREATE INDEX idx_reactions_channel_id ON Reactions(ChannelId);' + DECLARE CreateIndex BOOLEAN; + DECLARE CreateIndexQuery TEXT DEFAULT NULL; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Reactions' + AND table_schema = DATABASE() + AND column_name = 'ChannelId' + INTO AddChannelId; + + SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Reactions' + AND table_schema = DATABASE() + AND index_name = 'idx_reactions_channel_id' + INTO CreateIndex; + + IF AddChannelId THEN + SET AddChannelIdQuery = 'ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT ""'; + END IF; + + IF CreateIndex THEN + SET CreateIndexQuery = 'ADD INDEX idx_reactions_channel_id (ChannelId)'; + END IF; + + SET @alterQuery = CONCAT_WS(', ', AddChannelIdQuery, CreateIndexQuery); + IF @alterQuery <> '' THEN + SET @query = CONCAT('ALTER TABLE Reactions ', @alterQuery); + PREPARE stmt FROM @query; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + UPDATE Reactions SET ChannelId = COALESCE((select ChannelId from Posts where Posts.Id = Reactions.PostId), '') WHERE ChannelId=""; +END// +DELIMITER ; +SELECT CONCAT('-- ', NOW(), ' MigrateReactions procedure starting.') AS DEBUG; +CALL MigrateReactions (); +SELECT CONCAT('-- ', NOW(), ' MigrateReactions procedure finished.') AS DEBUG; +DROP PROCEDURE IF EXISTS MigrateReactions; + +/* ==> mysql/000091_create_post_reminder.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostReminders ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + TargetTime bigint, + INDEX idx_postreminders_targettime (TargetTime), + PRIMARY KEY (PostId, UserId) +); + +/* ==> mysql/000093_notify_admin.up.sql <== */ +CREATE TABLE IF NOT EXISTS NotifyAdmin ( + UserId varchar(26) NOT NULL, + CreateAt bigint(20) DEFAULT NULL, + RequiredPlan varchar(26) NOT NULL, + RequiredFeature varchar(100) NOT NULL, + Trial BOOLEAN NOT NULL, + PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) +); + +/* ==> mysql/000094_threads_teamid.up.sql <== */ +-- Replaced by 000096_threads_threadteamid.up.sql + +/* ==> mysql/000097_create_posts_priority.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostsPriority ( + PostId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + Priority varchar(32) NOT NULL, + RequestedAck tinyint(1), + PersistentNotifications tinyint(1), + PRIMARY KEY (PostId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +/* ==> mysql/000098_create_post_acknowledgements.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostAcknowledgements ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + AcknowledgedAt bigint(20) DEFAULT NULL, + PRIMARY KEY (PostId, UserId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +/* ==> mysql/000099_create_drafts.up.sql <== */ +/* ==> mysql/000100_add_draft_priority_column.up.sql <== */ +CREATE TABLE IF NOT EXISTS Drafts ( + CreateAt bigint(20) DEFAULT NULL, + UpdateAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + UserId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + RootId varchar(26) DEFAULT '', + Message text, + Props text, + FileIds text, + Priority text, + PRIMARY KEY (UserId, ChannelId, RootId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +/* ==> mysql/000101_create_true_up_review_history.up.sql <== */ +CREATE TABLE IF NOT EXISTS TrueUpReviewHistory ( + DueDate bigint(20), + Completed boolean, + PRIMARY KEY (DueDate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql new file mode 100644 index 0000000000..43af4c4844 --- /dev/null +++ b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql @@ -0,0 +1,168 @@ +/* Product notices are controlled externally, via the mattermost/notices repository. + When there is a new notice specified there, the server may have time, right after + the migration and before it is shut down, to download it and modify the + ProductNoticeViewState table, adding a row for all users that have not seen it or + removing old notices that no longer need to be shown. This can happen in the + UpdateProductNotices function that is executed periodically to update the notices + cache. The script will never do this, so we need to remove all rows in that table + to avoid any unwanted diff. */ +DELETE FROM ProductNoticeViewState; + +/* Remove migration-related tables that are only updated through the server to track which + migrations have been applied */ +DROP TABLE IF EXISTS db_lock; +DROP TABLE IF EXISTS db_migrations; + +/* The security update check in the server may update the LastSecurityTime system value. To + avoid any spurious difference in the migrations, we update it to a fixed value. */ +UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; + +/* The server migration may contain a row in the Systems table marking the onboarding as complete. + There are no migrations related to this, so we can simply drop it here. */ +DELETE FROM Systems WHERE Name = 'FirstAdminSetupComplete'; + +/* The server migration contains an in-app migration that add playbooks permissions to certain roles: + getPlaybooksPermissionsAddManageRoles, defined in https://github.com/mattermost/mattermost-server/blob/56a093ceaee6389a01a35b6d4626ef5a9fea4759/app/permissions_migrations.go#L1056-L1072 + The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add new rows to the Systems table marking the migrations as complete. + This in-app migration does not happen in the script, so we remove that rows here. */ +DELETE FROM Systems WHERE Name = 'playbooks_manage_roles'; + +/* The server migration contains an in-app migration that adds boards permissions to certain roles: + getProductsBoardsPermissions, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1074-L1093 + The specific roles (sysconsole_read_product_boards and sysconsole_write_product_boards) are removed in the procedure below, + but the migrations also adds a new row to the Systems table marking the migrations as complete. + This in-app migration does not happen in the script, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'products_boards'; + +/* The server migration contains an in-app migration that adds Ids to the Teams whose InviteId is an empty string: + doRemainingSchemaMigrations, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L515-L540 + The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the + Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'RemainingSchemaMigrations'; + +/* The server migration contains three in-app migration that adds a new role and new permissions + related to custom groups. The migrations are: + - doCustomGroupAdminRoleCreationMigration https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 + - getAddCustomUserGroupsPermissions https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L974-L995 + - getAddCustomUserGroupsPermissionRestore https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L997-L1019 + The specific roles and permissions are removed in the procedure below, but the migrations also + adds a new row to the Roles table for the new role and new rows to the Systems table marking the + migrations as complete. + This in-app migration does not happen in the script, so we remove that row here. */ +DELETE FROM Roles WHERE Name = 'system_custom_group_admin'; +DELETE FROM Systems WHERE Name = 'CustomGroupAdminRoleCreationMigrationComplete'; +DELETE FROM Systems WHERE Name = 'custom_groups_permissions'; +DELETE FROM Systems WHERE Name = 'custom_groups_permission_restore'; + +/* The server migration contains an in-app migration that updates the config, setting ServiceSettings.PostPriority + to true, doPostPriorityConfigDefaultTrueMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L542-L560 + The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the + Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ +DELETE FROM Systems WHERE Name = 'PostPriorityConfigDefaultTrueMigrationComplete'; + +/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: + 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column + do not appear in the diff. + 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as + this logic happens all in-app after the normal DB migrations. + 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of + the different permissions each role has. This change is the reason why we need a complex procedure, which creates + a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two + rows like: + Id: 'abcd' + Permissions: 'view_team read_public_channel invite_user' + Id: 'efgh' + Permissions: 'view_team create_emojis' + then the new temporary table will contain five rows like: + Id: 'abcd' + Permissions: 'view_team' + Id: 'abcd' + Permissions: 'read_public_channel' + Id: 'abcd' + Permissions: 'invite_user' + Id: 'efgh' + Permissions: 'view_team' + Id: 'efgh' + Permissions: 'create_emojis' +*/ + +DROP PROCEDURE IF EXISTS splitPermissions; +DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; + +DROP TEMPORARY TABLE IF EXISTS temp_roles; +CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); + +DELIMITER // + +/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted + in the temporary temp_roles table along with their corresponding ID. */ +CREATE PROCEDURE splitPermissions( + IN id varchar(26), + IN permissionsString longtext +) +BEGIN + DECLARE idx INT DEFAULT 0; + SELECT TRIM(permissionsString) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + WHILE idx > 0 DO + INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); + SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; + SELECT LOCATE(' ', permissionsString) INTO idx; + END WHILE; + INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); +END; // + +/* Main procedure that does update the Roles table */ +CREATE PROCEDURE sortAndFilterPermissionsInRoles() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE rolesId varchar(26) DEFAULT ''; + DECLARE rolesPermissions longtext DEFAULT ''; + DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ + UPDATE Roles SET UpdateAt = 1; + + /* Call splitPermissions for every row in the Roles table, thus populating the + temp_roles table. */ + OPEN cur1; + read_loop: LOOP + FETCH cur1 INTO rolesId, rolesPermissions; + IF done THEN + LEAVE read_loop; + END IF; + CALL splitPermissions(rolesId, rolesPermissions); + END LOOP; + CLOSE cur1; + + /* 2. Filter out the new permissions added by the in-app migrations */ + DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_read_products_boards'; + DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_write_products_boards'; + DELETE FROM temp_roles WHERE permission LIKE 'playbook_public_manage_roles'; + DELETE FROM temp_roles WHERE permission LIKE 'playbook_private_manage_roles'; + DELETE FROM temp_roles WHERE permission LIKE '%custom_group%'; + + /* Temporarily set to the maximum permitted value, since the call to group_concat + below needs a value bigger than the default */ + SET group_concat_max_len = 18446744073709551615; + + /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, + concatenated again as a space-separated string */ + UPDATE + Roles INNER JOIN ( + SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions + FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id + GROUP BY temp_roles.id + ) AS Sorted + ON Roles.Id = Sorted.Id + SET Roles.Permissions = Sorted.Permissions; + + /* Reset group_concat_max_len to its default value */ + SET group_concat_max_len = 1024; +END; // +DELIMITER ; + +CALL sortAndFilterPermissionsInRoles(); + +DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql new file mode 100644 index 0000000000..543d4f68bf --- /dev/null +++ b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql @@ -0,0 +1,599 @@ +/* ==> mysql/000041_create_upload_sessions.up.sql <== */ +/* Release 5.37 was meant to contain the index idx_uploadsessions_type, but a bug prevented that. + This part of the migration #41 adds such index */ + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'UploadSessions' + AND table_schema = DATABASE() + AND index_name = 'idx_uploadsessions_type' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_uploadsessions_type ON UploadSessions(Type);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000075_alter_upload_sessions_index.up.sql <== */ +DELIMITER // +CREATE PROCEDURE AlterIndex() +BEGIN + DECLARE columnName varchar(26) default ''; + + SELECT IFNULL(GROUP_CONCAT(column_name ORDER BY seq_in_index), '') INTO columnName + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'UploadSessions' + AND index_name = 'idx_uploadsessions_user_id' + GROUP BY index_name; + + IF columnName = 'Type' THEN + DROP INDEX idx_uploadsessions_user_id ON UploadSessions; + CREATE INDEX idx_uploadsessions_user_id ON UploadSessions(UserId); + END IF; +END// +DELIMITER ; +CALL AlterIndex(); +DROP PROCEDURE IF EXISTS AlterIndex; + +/* ==> mysql/000076_upgrade_lastrootpostat.up.sql <== */ +DELIMITER // +CREATE PROCEDURE Migrate_LastRootPostAt_Default () +BEGIN + IF ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Channels' + AND TABLE_SCHEMA = DATABASE() + AND COLUMN_NAME = 'LastRootPostAt' + AND (COLUMN_DEFAULT IS NULL OR COLUMN_DEFAULT != 0) + ) = 1 THEN + ALTER TABLE Channels ALTER COLUMN LastRootPostAt SET DEFAULT 0; + END IF; +END// +DELIMITER ; +CALL Migrate_LastRootPostAt_Default (); +DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt_Default; + +DELIMITER // +CREATE PROCEDURE Migrate_LastRootPostAt_Fix () +BEGIN + IF ( + SELECT COUNT(*) + FROM Channels + WHERE LastRootPostAt IS NULL + ) > 0 THEN + -- fixes migrate cte and sets the LastRootPostAt for channels that don't have it set + UPDATE + Channels + INNER JOIN ( + SELECT + Channels.Id channelid, + COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost + FROM + Channels + LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId + WHERE + Posts.RootId = '' + GROUP BY + Channels.Id) AS q ON q.channelid = Channels.Id + SET + LastRootPostAt = lastrootpost + WHERE + LastRootPostAt IS NULL; + + -- sets LastRootPostAt to 0, for channels with no posts + UPDATE Channels SET LastRootPostAt=0 WHERE LastRootPostAt IS NULL; + END IF; +END// +DELIMITER ; +CALL Migrate_LastRootPostAt_Fix (); +DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt_Fix; + +/* ==> mysql/000077_upgrade_users_v6.5.up.sql <== */ + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'AcceptedServiceTermsId' + ) > 0, + 'ALTER TABLE Users DROP COLUMN AcceptedServiceTermsId;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000078_create_oauth_mattermost_app_id.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'OAuthApps' + AND table_schema = DATABASE() + AND column_name = 'MattermostAppID' + ) > 0, + 'SELECT 1', + 'ALTER TABLE OAuthApps ADD COLUMN MattermostAppID varchar(32);' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000079_usergroups_displayname_index.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'UserGroups' + AND table_schema = DATABASE() + AND index_name = 'idx_usergroups_displayname' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000080_posts_createat_id.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND index_name = 'idx_posts_create_at_id' + ) > 0, + 'SELECT 1;', + 'CREATE INDEX idx_posts_create_at_id on Posts(CreateAt, Id) LOCK=NONE;' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000081_threads_deleteat.up.sql <== */ +-- Replaced by 000083_threads_threaddeleteat.up.sql + +/* ==> mysql/000082_upgrade_oauth_mattermost_app_id.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'OAuthApps' + AND table_schema = DATABASE() + AND column_name = 'MattermostAppID' + ) > 0, + 'UPDATE OAuthApps SET MattermostAppID = "" WHERE MattermostAppID IS NULL;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'OAuthApps' + AND table_schema = DATABASE() + AND column_name = 'MattermostAppID' + ) > 0, + 'ALTER TABLE OAuthApps MODIFY MattermostAppID varchar(32) NOT NULL DEFAULT "";', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000083_threads_threaddeleteat.up.sql <== */ +-- Drop any existing DeleteAt column from 000081_threads_deleteat.up.sql +SET @preparedStatement = (SELECT IF( + EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'DeleteAt' + ) > 0, + 'ALTER TABLE Threads DROP COLUMN DeleteAt;', + 'SELECT 1;' +)); + +PREPARE removeColumnIfExists FROM @preparedStatement; +EXECUTE removeColumnIfExists; +DEALLOCATE PREPARE removeColumnIfExists; + +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'ThreadDeleteAt' + ), + 'ALTER TABLE Threads ADD COLUMN ThreadDeleteAt bigint(20);', + 'SELECT 1;' +)); + +PREPARE addColumnIfNotExists FROM @preparedStatement; +EXECUTE addColumnIfNotExists; +DEALLOCATE PREPARE addColumnIfNotExists; + +UPDATE Threads, Posts +SET Threads.ThreadDeleteAt = Posts.DeleteAt +WHERE Posts.Id = Threads.PostId +AND Threads.ThreadDeleteAt IS NULL; + +/* ==> mysql/000084_recent_searches.up.sql <== */ +CREATE TABLE IF NOT EXISTS RecentSearches ( + UserId CHAR(26), + SearchPointer int, + Query json, + CreateAt bigint NOT NULL, + PRIMARY KEY (UserId, SearchPointer) +); +/* ==> mysql/000085_fileinfo_add_archived_column.up.sql <== */ + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'FileInfo' + AND table_schema = DATABASE() + AND column_name = 'Archived' + ) > 0, + 'SELECT 1', + 'ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false;' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000086_add_cloud_limits_archived.up.sql <== */ +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Teams' + AND table_schema = DATABASE() + AND column_name = 'CloudLimitsArchived' + ), + 'ALTER TABLE Teams ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE;', + 'SELECT 1' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +/* ==> mysql/000087_sidebar_categories_index.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'SidebarCategories' + AND table_schema = DATABASE() + AND index_name = 'idx_sidebarcategories_userid_teamid' + ) > 0, + 'SELECT 1;', + 'CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE;' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000088_remaining_migrations.up.sql <== */ +DROP TABLE IF EXISTS JobStatuses; + +DROP TABLE IF EXISTS PasswordRecovery; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'ThemeProps' + ) > 0, + 'INSERT INTO Preferences(UserId, Category, Name, Value) SELECT Id, \'\', \'\', ThemeProps FROM Users WHERE Users.ThemeProps != \'null\'', + 'SELECT 1' +)); + +PREPARE migrateTheme FROM @preparedStatement; +EXECUTE migrateTheme; +DEALLOCATE PREPARE migrateTheme; + +-- We have to do this twice because the prepared statement doesn't support multiple SQL queries +-- in a single string. + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Users' + AND table_schema = DATABASE() + AND column_name = 'ThemeProps' + ) > 0, + 'ALTER TABLE Users DROP COLUMN ThemeProps', + 'SELECT 1' +)); + +PREPARE migrateTheme FROM @preparedStatement; +EXECUTE migrateTheme; +DEALLOCATE PREPARE migrateTheme; + +/* ==> mysql/000089_add-channelid-to-reaction.up.sql <== */ +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Reactions' + AND table_schema = DATABASE() + AND column_name = 'ChannelId' + ), + 'ALTER TABLE Reactions ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT "";', + 'SELECT 1;' +)); + +PREPARE addColumnIfNotExists FROM @preparedStatement; +EXECUTE addColumnIfNotExists; +DEALLOCATE PREPARE addColumnIfNotExists; + + +UPDATE Reactions SET ChannelId = COALESCE((select ChannelId from Posts where Posts.Id = Reactions.PostId), '') WHERE ChannelId=""; + + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Reactions' + AND table_schema = DATABASE() + AND index_name = 'idx_reactions_channel_id' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_reactions_channel_id ON Reactions(ChannelId);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000090_create_enums.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Channels' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND column_type != 'ENUM("D", "O", "G", "P")' + ) > 0, + 'ALTER TABLE Channels MODIFY COLUMN Type ENUM("D", "O", "G", "P");', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Teams' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND column_type != 'ENUM("I", "O")' + ) > 0, + 'ALTER TABLE Teams MODIFY COLUMN Type ENUM("I", "O");', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'UploadSessions' + AND table_schema = DATABASE() + AND column_name = 'Type' + AND column_type != 'ENUM("attachment", "import")' + ) > 0, + 'ALTER TABLE UploadSessions MODIFY COLUMN Type ENUM("attachment", "import");', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; +/* ==> mysql/000091_create_post_reminder.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostReminders ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + TargetTime bigint, + PRIMARY KEY (PostId, UserId) +); + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'PostReminders' + AND table_schema = DATABASE() + AND index_name = 'idx_postreminders_targettime' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_postreminders_targettime ON PostReminders(TargetTime);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; +/* ==> mysql/000092_add_createat_to_teammembers.up.sql <== */ +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND column_name = 'CreateAt' + ), + 'ALTER TABLE TeamMembers ADD COLUMN CreateAt bigint DEFAULT 0;', + 'SELECT 1;' +)); + +PREPARE addColumnIfNotExists FROM @preparedStatement; +EXECUTE addColumnIfNotExists; +DEALLOCATE PREPARE addColumnIfNotExists; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'TeamMembers' + AND table_schema = DATABASE() + AND index_name = 'idx_teammembers_create_at' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_teammembers_createat ON TeamMembers(CreateAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; + +/* ==> mysql/000093_notify_admin.up.sql <== */ +CREATE TABLE IF NOT EXISTS NotifyAdmin ( + UserId varchar(26) NOT NULL, + CreateAt bigint(20) DEFAULT NULL, + RequiredPlan varchar(26) NOT NULL, + RequiredFeature varchar(100) NOT NULL, + Trial BOOLEAN NOT NULL, + PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) +); + +/* ==> mysql/000094_threads_teamid.up.sql <== */ +-- Replaced by 000096_threads_threadteamid.up.sql + +/* ==> mysql/000095_remove_posts_parentid.up.sql <== */ +-- While upgrading from 5.x to 6.x with manual queries, there is a chance that this +-- migration is skipped. In that case, we need to make sure that the column is dropped. + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Posts' + AND table_schema = DATABASE() + AND column_name = 'ParentId' + ) > 0, + 'ALTER TABLE Posts DROP COLUMN ParentId;', + 'SELECT 1' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000096_threads_threadteamid.up.sql <== */ +-- Drop any existing TeamId column from 000094_threads_teamid.up.sql +SET @preparedStatement = (SELECT IF( + EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'TeamId' + ) > 0, + 'ALTER TABLE Threads DROP COLUMN TeamId;', + 'SELECT 1;' +)); + +PREPARE removeColumnIfExists FROM @preparedStatement; +EXECUTE removeColumnIfExists; +DEALLOCATE PREPARE removeColumnIfExists; + +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Threads' + AND table_schema = DATABASE() + AND column_name = 'ThreadTeamId' + ), + 'ALTER TABLE Threads ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL;', + 'SELECT 1;' +)); + +PREPARE addColumnIfNotExists FROM @preparedStatement; +EXECUTE addColumnIfNotExists; +DEALLOCATE PREPARE addColumnIfNotExists; + +UPDATE Threads, Channels +SET Threads.ThreadTeamId = Channels.TeamId +WHERE Channels.Id = Threads.ChannelId +AND Threads.ThreadTeamId IS NULL; + +/* ==> mysql/000097_create_posts_priority.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostsPriority ( + PostId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + Priority varchar(32) NOT NULL, + RequestedAck tinyint(1), + PersistentNotifications tinyint(1), + PRIMARY KEY (PostId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @preparedStatement = (SELECT IF( + NOT EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'ChannelMembers' + AND table_schema = DATABASE() + AND column_name = 'UrgentMentionCount' + ), + 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', + 'SELECT 1;' +)); + +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +/* ==> mysql/000098_create_post_acknowledgements.up.sql <== */ +CREATE TABLE IF NOT EXISTS PostAcknowledgements ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + AcknowledgedAt bigint(20) DEFAULT NULL, + PRIMARY KEY (PostId, UserId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +/* ==> mysql/000099_create_drafts.up.sql <== */ +CREATE TABLE IF NOT EXISTS Drafts ( + CreateAt bigint(20) DEFAULT NULL, + UpdateAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + UserId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + RootId varchar(26) DEFAULT '', + Message text, + Props text, + FileIds text, + PRIMARY KEY (UserId, ChannelId, RootId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +/* ==> mysql/000100_add_draft_priority_column.up.sql <== */ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'Drafts' + AND table_schema = DATABASE() + AND column_name = 'Priority' + ) > 0, + 'SELECT 1', + 'ALTER TABLE Drafts ADD COLUMN Priority text;' +)); + +PREPARE alterIfExists FROM @preparedStatement; +EXECUTE alterIfExists; +DEALLOCATE PREPARE alterIfExists; + +/* ==> mysql/000101_create_true_up_review_history.up.sql <== */ +CREATE TABLE IF NOT EXISTS TrueUpReviewHistory ( + DueDate bigint(20), + Completed boolean, + PRIMARY KEY (DueDate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql b/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql new file mode 100644 index 0000000000..4c06e1ba19 --- /dev/null +++ b/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql @@ -0,0 +1,23 @@ +/* The sessions in the DB dump may have expired before the CI tests run, making + the server remove the rows and generating a spurious diff that we want to avoid. + In order to do so, we mark all sessions' ExpiresAt value to 0, so they never expire. */ +UPDATE Sessions SET ExpiresAt = 0; + +/* The dump may not contain a system-bot user, in which case the server will create + one if it's not shutdown before a job requests it. This situation creates a flaky + tests in which, in rare ocassions, the system-bot is indeed created, generating a + spurious diff. We avoid this by making sure that there is a system-bot user and + corresponding bot */ +DELIMITER // +CREATE PROCEDURE AddSystemBotIfNeeded () +BEGIN + DECLARE CreateSystemBot BOOLEAN; + SELECT COUNT(*) = 0 FROM Users WHERE Username = 'system-bot' INTO CreateSystemBot; + IF CreateSystemBot THEN + /* These values are retrieved from a real system-bot created by a server */ + INSERT INTO `Bots` VALUES ('nc7y5x1i8jgr9btabqo5m3579c','','phxrtijfrtfg7k4bwj9nophqyc',0,1681308600015,1681308600015,0); + INSERT INTO `Users` VALUES ('nc7y5x1i8jgr9btabqo5m3579c',1681308600014,1681308600014,0,'system-bot','',NULL,'','system-bot@localhost',0,'','System','','','system_user',0,'{}','{\"push\": \"mention\", \"email\": \"true\", \"channel\": \"true\", \"desktop\": \"mention\", \"comments\": \"never\", \"first_name\": \"false\", \"push_status\": \"away\", \"mention_keys\": \"\", \"push_threads\": \"all\", \"desktop_sound\": \"true\", \"email_threads\": \"all\", \"desktop_threads\": \"all\"}',1681308600014,0,0,'en','{\"manualTimezone\": \"\", \"automaticTimezone\": \"\", \"useAutomaticTimezone\": \"true\"}',0,'',NULL); + END IF; +END// +DELIMITER ; +CALL AddSystemBotIfNeeded(); From 7c78fe558971cbf9cdfe95ac9e16f1422675ad2d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Thu, 20 Apr 2023 14:20:12 -0400 Subject: [PATCH 090/113] Fix style issues with workspace deletion modal. --- .../billing/delete_workspace/delete_workspace_modal.scss | 7 ++++++- .../billing/delete_workspace/delete_workspace_modal.tsx | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss index 2bcbbebd2a..d93d35824c 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss @@ -12,6 +12,10 @@ } } + &__Icon { + padding-top: 8px; + } + &__Title { color: var(--sys-denim-center-channel-text); font-family: Metropolis; @@ -22,10 +26,11 @@ &__Usage { text-align: left; + color: var(--sys-center-channel-text); &-Highlighted { color: black; - font-weight: 700; + font-weight: bold; } } diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx index 5fc7c7c2a7..aaff7fab71 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx @@ -186,8 +186,8 @@ export default function DeleteWorkspaceModal(props: Props) { className='DeleteWorkspaceModal' onExited={handleClickCancel} > -
- +
+
Date: Thu, 20 Apr 2023 14:34:36 -0400 Subject: [PATCH 091/113] Add Makefile target to update development Docker container configuration (#22813) Co-authored-by: Mattermost Build --- server/Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/Makefile b/server/Makefile index 62e30b7a1a..143d6e7cc9 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race new-migration migrations-extract +.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race new-migration migrations-extract ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -237,6 +237,11 @@ else endif endif +update-docker: stop-docker ## Updates the docker containers for local development. + @echo Updating docker containers + + $(GO) run ./build/docker-compose-generator/main.go $(ENABLED_DOCKER_SERVICES) | docker-compose -f docker-compose.makefile.yml -f /dev/stdin $(DOCKER_COMPOSE_OVERRIDE) up --no-start + run-haserver: ifeq ($(BUILD_ENTERPRISE_READY),true) @echo Starting mattermost in an HA topology '(3 node cluster)' From 6edf8ea994054f2affc8eb7b9938ca01585f627b Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Thu, 20 Apr 2023 14:34:58 -0400 Subject: [PATCH 092/113] lint. --- .../billing/delete_workspace/delete_workspace_modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx index aaff7fab71..16ca7b506f 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.tsx @@ -186,7 +186,7 @@ export default function DeleteWorkspaceModal(props: Props) { className='DeleteWorkspaceModal' onExited={handleClickCancel} > -
+
From fd1f62f9d17ae044907cff12ccc01d2b049b475e Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Thu, 20 Apr 2023 15:21:02 -0400 Subject: [PATCH 093/113] lint. --- .../billing/delete_workspace/delete_workspace_modal.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss index d93d35824c..7afd7e64a2 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss @@ -25,8 +25,8 @@ } &__Usage { - text-align: left; color: var(--sys-center-channel-text); + text-align: left; &-Highlighted { color: black; From f58648d493f1d9e0ee179d5e55bc2a4ffd3ced24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Mondrag=C3=B3n?= <79058848+julmondragon@users.noreply.github.com> Date: Thu, 20 Apr 2023 14:46:38 -0500 Subject: [PATCH 094/113] MM-52161_The Marketplace modal has some display issues (#23011) --- .../marketplace_item/marketplace_item.tsx | 59 +++++++++++++++++-- .../plugin_marketplace/marketplace_modal.scss | 16 +++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item.tsx index 518b522b98..ac0c1926bd 100644 --- a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item.tsx +++ b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item.tsx @@ -75,7 +75,33 @@ export type MarketplaceItemProps = { versionLabel: JSX.Element| null; }; -export default class MarketplaceItem extends React.PureComponent { +type MarketplaceItemState = { + showTooltip: boolean; +}; + +export default class MarketplaceItem extends React.PureComponent { + descriptionRef: React.RefObject; + + constructor(props: MarketplaceItemProps) { + super(props); + + this.descriptionRef = React.createRef(); + + this.state = { + showTooltip: false, + }; + } + + componentDidMount(): void { + this.enableToolTipIfNeeded(); + } + + enableToolTipIfNeeded = (): void => { + const element = this.descriptionRef.current; + const showTooltip = element && element.offsetWidth < element.scrollWidth; + this.setState({showTooltip: Boolean(showTooltip)}); + }; + render(): JSX.Element { const {labels = null} = this.props; let icon; @@ -105,12 +131,37 @@ export default class MarketplaceItem extends React.PureComponent ); - const description = ( -

- {this.props.error || this.props.description} + const descriptionText = this.props.error || this.props.description; + let description = ( +

+ {descriptionText}

); + if (this.state.showTooltip) { + const displayNameToolTip = ( + + {descriptionText} + + ); + + description = ( + + {description} + + ); + } + let pluginDetails; if (this.props.homepageUrl) { pluginDetails = ( diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss index 5a0495c0fc..1fd73cc612 100644 --- a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss +++ b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss @@ -90,6 +90,7 @@ overflow-y: scroll; .more-modal__row { + overflow: hidden; min-height: 80px; padding: 16px 20px; border-bottom: none; @@ -99,10 +100,11 @@ } .update { - padding: 10px 10px 0 0; - border-top: 1px solid rgba(black, 0.1); - margin: 10px 10px 0 0; font-size: 0.9em; + + a { + text-decoration: none; + } } .more-modal__details { @@ -117,7 +119,7 @@ .more-modal__description { margin: 2px 0 0; - color: rgba(var(--center-channel-color-rgb), 0.64); + color: var(--center-channel-color-rgb); font-size: 14px; font-weight: 400; line-height: 20px; @@ -275,3 +277,9 @@ height: 390px; } } + +.more-modal__description-tooltip { + .tooltip-inner { + text-align: left; + } +} From 35a16855170108f79ec3f74b52703e13eba25a5a Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Thu, 20 Apr 2023 15:52:03 -0400 Subject: [PATCH 095/113] change font color from sys-center-console-text to center-console-text. --- .../billing/delete_workspace/delete_workspace_modal.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss index 7afd7e64a2..aee1487a3d 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss @@ -25,7 +25,7 @@ } &__Usage { - color: var(--sys-center-channel-text); + color: var(--center-channel-text); text-align: left; &-Highlighted { @@ -35,6 +35,7 @@ } &__Warning { + color: var(--center-channel-text); text-align: left; } From 26ee59e3c2fdb8bcd87a563f80b337fe5f9e1b58 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Thu, 20 Apr 2023 15:54:38 -0400 Subject: [PATCH 096/113] Change to -center-channel-color after seeing warning not to use -center-channel-text. --- .../billing/delete_workspace/delete_workspace_modal.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss index aee1487a3d..514dd90e0a 100644 --- a/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss +++ b/webapp/channels/src/components/admin_console/billing/delete_workspace/delete_workspace_modal.scss @@ -25,7 +25,7 @@ } &__Usage { - color: var(--center-channel-text); + color: var(--center-channel-color); text-align: left; &-Highlighted { @@ -35,7 +35,7 @@ } &__Warning { - color: var(--center-channel-text); + color: var(--center-channel-color); text-align: left; } From 344e882f042612a37c73ee610805b188df029bcf Mon Sep 17 00:00:00 2001 From: Vishal Date: Fri, 21 Apr 2023 11:55:04 +0530 Subject: [PATCH 097/113] Replace string concatenation with StringBuilder (#23021) --- server/platform/shared/markdown/inlines.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/platform/shared/markdown/inlines.go b/server/platform/shared/markdown/inlines.go index 43dee3bd32..973ae5ed21 100644 --- a/server/platform/shared/markdown/inlines.go +++ b/server/platform/shared/markdown/inlines.go @@ -628,7 +628,7 @@ func MergeInlineText(inlines []Inline) []Inline { } func Unescape(markdown string) string { - ret := "" + var ret strings.Builder position := 0 for position < len(markdown) { @@ -637,27 +637,27 @@ func Unescape(markdown string) string { switch c { case '\\': if position+1 < len(markdown) && isEscapableByte(markdown[position+1]) { - ret += string(markdown[position+1]) + ret.WriteByte(markdown[position+1]) position += 2 } else { - ret += `\` + ret.WriteString(`\`) position++ } case '&': position++ if semicolon := strings.IndexByte(markdown[position:], ';'); semicolon == -1 { - ret += "&" + ret.WriteString("&") } else if s := CharacterReference(markdown[position : position+semicolon]); s != "" { position += semicolon + 1 - ret += s + ret.WriteString(s) } else { - ret += "&" + ret.WriteString("&") } default: - ret += string(c) + ret.WriteRune(c) position += cSize } } - return ret + return ret.String() } From 041cbe2d24e22ae415511016fe4501d0341f02ca Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Fri, 21 Apr 2023 10:44:52 +0200 Subject: [PATCH 098/113] Updates the check for the schema_migrations information (#23032) The old check was using `sq.Eq`, which replaces its values with strings, and for the case of `table_schema` we want to call a function. --- server/boards/services/store/sqlstore/schema_table_migration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/boards/services/store/sqlstore/schema_table_migration.go b/server/boards/services/store/sqlstore/schema_table_migration.go index cdb0f4d628..8ec5f154a9 100644 --- a/server/boards/services/store/sqlstore/schema_table_migration.go +++ b/server/boards/services/store/sqlstore/schema_table_migration.go @@ -126,7 +126,7 @@ func (s *SQLStore) isSchemaMigrationNeeded() (bool, error) { case model.MysqlDBType: query = query.Where(sq.Eq{"TABLE_SCHEMA": s.schemaName}) case model.PostgresDBType: - query = query.Where(sq.Eq{"TABLE_SCHEMA": "current_schema()"}) + query = query.Where("table_schema = current_schema()") } rows, err := query.Query() From 67735be261d74bb89fa4fd53aced4db35f6d421e Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Fri, 21 Apr 2023 22:23:56 +0530 Subject: [PATCH 099/113] MM-52216: Trim errors (#23040) https://mattermost.atlassian.net/browse/MM-52216 ```release-note NONE ``` --- server/channels/store/sqlstore/channel_store.go | 14 +++++++------- server/channels/store/sqlstore/file_info_store.go | 2 +- server/channels/store/sqlstore/post_store.go | 2 +- server/channels/store/sqlstore/utils.go | 11 +++++++++++ server/model/utils.go | 8 +++++++- server/model/utils_test.go | 7 +++++++ 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 2f8e91b9dc..d240ec9dcd 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -3073,7 +3073,7 @@ func (s SqlChannelStore) Autocomplete(userID, term string, includeDeleted, isGue channels := model.ChannelListWithTeamData{} err = s.GetReplicaX().Select(&channels, sql, args...) if err != nil { - return nil, errors.Wrapf(err, "could not find channel with term=%s", term) + return nil, errors.Wrapf(err, "could not find channel with term=%s", trimInput(term)) } return channels, nil } @@ -3186,7 +3186,7 @@ func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID strin // query the database err = s.GetReplicaX().Select(&channels, sql, args...) if err != nil { - return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", term) + return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term)) } directChannels, err := s.autocompleteInTeamForSearchDirectMessages(userID, term) @@ -3242,7 +3242,7 @@ func (s SqlChannelStore) autocompleteInTeamForSearchDirectMessages(userID string // query the channel list from the database using SQLX channels := model.ChannelList{} if err := s.GetReplicaX().Select(&channels, sql, args...); err != nil { - return nil, errors.Wrapf(err, "failed to find Channels with term='%s' (%s %% %v)", term, sql, args) + return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term)) } return channels, nil @@ -3461,7 +3461,7 @@ func (s SqlChannelStore) SearchAllChannels(term string, opts store.ChannelSearch } channels := model.ChannelListWithTeamData{} if err2 := s.GetReplicaX().Select(&channels, queryString, args...); err2 != nil { - return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", term) + return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", trimInput(term)) } var totalCount int64 @@ -3474,7 +3474,7 @@ func (s SqlChannelStore) SearchAllChannels(term string, opts store.ChannelSearch return nil, 0, errors.Wrap(err, "channel_tosql") } if err2 := s.GetReplicaX().Get(&totalCount, queryString, args...); err2 != nil { - return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", term) + return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", trimInput(term)) } } else { totalCount = int64(len(channels)) @@ -3651,7 +3651,7 @@ func (s SqlChannelStore) performSearch(searchQuery sq.SelectBuilder, term string channels := model.ChannelList{} err = s.GetReplicaX().Select(&channels, sql, args...) if err != nil { - return channels, errors.Wrapf(err, "failed to find Channels with term='%s'", term) + return channels, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term)) } return channels, nil @@ -3744,7 +3744,7 @@ func (s SqlChannelStore) SearchGroupChannels(userId, term string) (model.Channel groupChannels := model.ChannelList{} if err := s.GetReplicaX().Select(&groupChannels, sql, params...); err != nil { - return nil, errors.Wrapf(err, "failed to find Channels with term='%s' and userId=%s", term, userId) + return nil, errors.Wrapf(err, "failed to find Channels with term='%s' and userId=%s", trimInput(term), userId) } return groupChannels, nil } diff --git a/server/channels/store/sqlstore/file_info_store.go b/server/channels/store/sqlstore/file_info_store.go index 0e804605c9..52c5164136 100644 --- a/server/channels/store/sqlstore/file_info_store.go +++ b/server/channels/store/sqlstore/file_info_store.go @@ -681,7 +681,7 @@ func (fs SqlFileInfoStore) Search(paramsList []*model.SearchParams, userId, team items := []fileInfoWithChannelID{} err = fs.GetSearchReplicaX().Select(&items, queryString, args...) if err != nil { - mlog.Warn("Query error searching files.", mlog.Err(err)) + mlog.Warn("Query error searching files.", mlog.String("error", trimInput(err.Error()))) // Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results. } else { for _, item := range items { diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index e60583fe75..ad95fce3a9 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -2075,7 +2075,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search var posts []*model.Post if err := s.GetSearchReplicaX().Select(&posts, searchQuery, searchQueryArgs...); err != nil { - mlog.Warn("Query error searching posts.", mlog.Err(err)) + mlog.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error()))) // Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results. } else { for _, p := range posts { diff --git a/server/channels/store/sqlstore/utils.go b/server/channels/store/sqlstore/utils.go index 69d21ab824..753d5d3933 100644 --- a/server/channels/store/sqlstore/utils.go +++ b/server/channels/store/sqlstore/utils.go @@ -233,3 +233,14 @@ func SanitizeDataSource(driverName, dataSource string) (string, error) { return "", errors.New("invalid drivername. Not postgres or mysql.") } } + +const maxTokenSize = 50 + +// trimInput limits the string to a max size to prevent clogging up disk space +// while logging +func trimInput(input string) string { + if len(input) > maxTokenSize { + input = input[:maxTokenSize] + "..." + } + return input +} diff --git a/server/model/utils.go b/server/model/utils.go index a46bddabae..956aa8caf3 100644 --- a/server/model/utils.go +++ b/server/model/utils.go @@ -251,6 +251,8 @@ type AppError struct { wrapped error } +const maxErrorLength = 1024 + func (er *AppError) Error() string { var sb strings.Builder @@ -276,7 +278,11 @@ func (er *AppError) Error() string { sb.WriteString(err.Error()) } - return sb.String() + res := sb.String() + if len(res) > maxErrorLength { + res = res[:maxErrorLength] + "..." + } + return res } func (er *AppError) Translate(T i18n.TranslateFunc) { diff --git a/server/model/utils_test.go b/server/model/utils_test.go index 606477d750..7b5e099e96 100644 --- a/server/model/utils_test.go +++ b/server/model/utils_test.go @@ -116,6 +116,13 @@ func TestAppErrorRender(t *testing.T) { aerr := NewAppError("here", "message", nil, "details", http.StatusTeapot).Wrap(fmt.Errorf("my error (%w)", fmt.Errorf("inner error"))) assert.EqualError(t, aerr, "here: message, details, my error (inner error)") }) + + t.Run("MaxLength", func(t *testing.T) { + str := strings.Repeat("error", 65536) + msg := "msg" + aerr := NewAppError("id", msg, nil, str, http.StatusTeapot).Wrap(errors.New(str)) + assert.Len(t, aerr.Error(), maxErrorLength+len(msg)) + }) } func TestAppErrorSerialize(t *testing.T) { From 54db770811ea141af8cc62bdee6cd4e82f013986 Mon Sep 17 00:00:00 2001 From: Nathaniel Allred Date: Fri, 21 Apr 2023 12:31:27 -0500 Subject: [PATCH 100/113] Mm 51788 non-admins do not trigger a request to fetch stripe customer in cloud instances (#22821) * do not query for customer if not a cloud admin --- .../payment_announcement_bar/index.test.tsx | 97 +++++++++++++++++++ .../payment_announcement_bar/index.ts | 53 ---------- .../payment_announcement_bar/index.tsx | 89 +++++++++++++++++ .../payment_announcement_bar.tsx | 96 ------------------ webapp/channels/src/utils/constants.tsx | 1 + 5 files changed, 187 insertions(+), 149 deletions(-) create mode 100644 webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.test.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.ts create mode 100644 webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/payment_announcement_bar/payment_announcement_bar.tsx diff --git a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.test.tsx b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.test.tsx new file mode 100644 index 0000000000..8e610d2d24 --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {screen} from '@testing-library/react'; +import {renderWithIntlAndStore} from 'tests/react_testing_utils'; +import * as cloudActions from 'mattermost-redux/actions/cloud'; + +import {CloudProducts} from 'utils/constants'; + +import PaymentAnnouncementBar from './'; + +jest.mock('mattermost-redux/actions/cloud', () => { + const original = jest.requireActual('mattermost-redux/actions/cloud'); + return { + ...original, + __esModule: true, + + // just testing that it fired, not that the result updated or anything like that + getCloudCustomer: jest.fn(() => ({type: 'bogus'})), + }; +}); + +describe('PaymentAnnouncementBar', () => { + const happyPathStore = { + entities: { + users: { + currentUserId: 'me', + profiles: { + me: { + roles: 'system_admin', + }, + }, + }, + general: { + license: { + Cloud: 'true', + }, + }, + cloud: { + subscription: { + product_id: 'prod_something', + last_invoice: { + status: 'failed', + }, + }, + customer: { + payment_method: { + exp_month: 12, + exp_year: (new Date()).getFullYear() + 1, + }, + }, + products: { + prod_something: { + id: 'prod_something', + sku: CloudProducts.PROFESSIONAL, + }, + }, + }, + }, + views: { + announcementBar: { + announcementBarState: { + announcementBarCount: 1, + }, + }, + }, + }; + + it('when most recent payment failed, shows that', () => { + renderWithIntlAndStore(, happyPathStore); + screen.getByText('Your most recent payment failed'); + }); + + it('when card is expired, shows that', () => { + const store = JSON.parse(JSON.stringify(happyPathStore)); + store.entities.cloud.customer.payment_method.exp_year = (new Date()).getFullYear() - 1; + store.entities.cloud.subscription.last_invoice.status = 'success'; + renderWithIntlAndStore(, store); + screen.getByText('Your credit card has expired', {exact: false}); + }); + + it('when needed, fetches, customer', () => { + const store = JSON.parse(JSON.stringify(happyPathStore)); + store.entities.cloud.customer = null; + store.entities.cloud.subscription.last_invoice.status = 'success'; + renderWithIntlAndStore(, store); + expect(cloudActions.getCloudCustomer).toHaveBeenCalled(); + }); + + it('when not an admin, does not fetch customer', () => { + const store = JSON.parse(JSON.stringify(happyPathStore)); + store.entities.users.profiles.me.roles = ''; + renderWithIntlAndStore(, store); + expect(cloudActions.getCloudCustomer).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.ts b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.ts deleted file mode 100644 index 86e3bd5f05..0000000000 --- a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import {bindActionCreators, Dispatch} from 'redux'; - -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {GenericAction} from 'mattermost-redux/types/actions'; -import {getCloudSubscription, getCloudCustomer} from 'mattermost-redux/actions/cloud'; - -import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; -import { - getCloudSubscription as selectCloudSubscription, - getCloudCustomer as selectCloudCustomer, - getSubscriptionProduct, -} from 'mattermost-redux/selectors/entities/cloud'; -import {CloudProducts} from 'utils/constants'; - -import {openModal} from 'actions/views/modals'; - -import {GlobalState} from 'types/store'; - -import PaymentAnnouncementBar from './payment_announcement_bar'; - -function mapStateToProps(state: GlobalState) { - const subscription = selectCloudSubscription(state); - const customer = selectCloudCustomer(state); - const subscriptionProduct = getSubscriptionProduct(state); - return { - userIsAdmin: isCurrentUserSystemAdmin(state), - isCloud: getLicense(state).Cloud === 'true', - subscription, - customer, - isStarterFree: subscriptionProduct?.sku === CloudProducts.STARTER, - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators( - { - savePreferences, - openModal, - getCloudSubscription, - getCloudCustomer, - }, - dispatch, - ), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(PaymentAnnouncementBar); diff --git a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx new file mode 100644 index 0000000000..f14153ad8e --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector, useDispatch} from 'react-redux'; +import {isEmpty} from 'lodash'; + +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import {getCloudCustomer} from 'mattermost-redux/actions/cloud'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import { + getCloudSubscription as selectCloudSubscription, + getCloudCustomer as selectCloudCustomer, + getSubscriptionProduct, +} from 'mattermost-redux/selectors/entities/cloud'; +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; + +import {getHistory} from 'utils/browser_history'; +import {isCustomerCardExpired} from 'utils/cloud_utils'; +import {AnnouncementBarTypes, CloudProducts, ConsolePages} from 'utils/constants'; +import {t} from 'utils/i18n'; + +import AnnouncementBar from '../default_announcement_bar'; + +export default function PaymentAnnouncementBar() { + const [requestedCustomer, setRequestedCustomer] = useState(false); + const dispatch = useDispatch(); + const subscription = useSelector(selectCloudSubscription); + const customer = useSelector(selectCloudCustomer); + const isStarterFree = useSelector(getSubscriptionProduct)?.sku === CloudProducts.STARTER; + const userIsAdmin = useSelector(isCurrentUserSystemAdmin); + const isCloud = useSelector(getLicense).Cloud === 'true'; + + useEffect(() => { + if (isCloud && !isStarterFree && isEmpty(customer) && userIsAdmin && !requestedCustomer) { + setRequestedCustomer(true); + dispatch(getCloudCustomer()); + } + }, + [isCloud, isStarterFree, customer, userIsAdmin, requestedCustomer]); + + const mostRecentPaymentFailed = subscription?.last_invoice?.status === 'failed'; + + if ( + // Prevents banner flashes if the subscription hasn't been loaded yet + isEmpty(subscription) || + isStarterFree || + !isCloud || + !userIsAdmin || + isEmpty(customer) || + (!isCustomerCardExpired(customer) && !mostRecentPaymentFailed) + ) { + return null; + } + + const updatePaymentInfo = () => { + getHistory().push(ConsolePages.PAYMENT_INFO); + }; + + let message = ( + + ); + + if (mostRecentPaymentFailed) { + message = ( + + ); + } + + return ( + + ); +} diff --git a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/payment_announcement_bar.tsx b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/payment_announcement_bar.tsx deleted file mode 100644 index 5fe7c7fa4b..0000000000 --- a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/payment_announcement_bar.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {isEmpty} from 'lodash'; - -import {CloudCustomer, Subscription} from '@mattermost/types/cloud'; - -import {getHistory} from 'utils/browser_history'; -import {isCustomerCardExpired} from 'utils/cloud_utils'; -import {AnnouncementBarTypes} from 'utils/constants'; -import {t} from 'utils/i18n'; - -import AnnouncementBar from '../default_announcement_bar'; - -type Props = { - userIsAdmin: boolean; - isCloud: boolean; - subscription?: Subscription; - customer?: CloudCustomer; - isStarterFree: boolean; - actions: { - getCloudSubscription: () => void; - getCloudCustomer: () => void; - }; -}; - -class PaymentAnnouncementBar extends React.PureComponent { - async componentDidMount() { - if (isEmpty(this.props.customer)) { - await this.props.actions.getCloudCustomer(); - } - } - - isMostRecentPaymentFailed = () => { - return this.props.subscription?.last_invoice?.status === 'failed'; - }; - - shouldShowBanner = () => { - const {userIsAdmin, isCloud, subscription} = this.props; - - // Prevents banner flashes if the subscription hasn't been loaded yet - if (subscription === null) { - return false; - } - - if (this.props.isStarterFree) { - return false; - } - - if (!isCloud) { - return false; - } - - if (!userIsAdmin) { - return false; - } - - if (!isCustomerCardExpired(this.props.customer) && !this.isMostRecentPaymentFailed()) { - return false; - } - - return true; - }; - - updatePaymentInfo = () => { - getHistory().push('/admin_console/billing/payment_info'); - }; - - render() { - if (isEmpty(this.props.customer) || isEmpty(this.props.subscription)) { - return null; - } - - if (!this.shouldShowBanner()) { - return null; - } - - return ( - - - ); - } -} - -export default PaymentAnnouncementBar; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 652e102586..97ea8d65d0 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2021,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', + PAYMENT_INFO: '/admin_console/billing/payment_info', BILLING_HISTORY: '/admin_console/billing/billing_history', }; From 94de9c8175e68a1b315ba203cc9a43e4e6769d27 Mon Sep 17 00:00:00 2001 From: Agniva De Sarker Date: Sat, 22 Apr 2023 10:14:54 +0530 Subject: [PATCH 101/113] MM-52352: Limit channel search results (#23070) https://mattermost.atlassian.net/browse/MM-52352 ```release-note NONE ``` --- .../channels/store/sqlstore/channel_store.go | 3 +- .../channels/store/storetest/channel_store.go | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index d240ec9dcd..38c35239c0 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -3035,7 +3035,8 @@ func (s SqlChannelStore) Autocomplete(userID, term string, includeDeleted, isGue sq.Expr("t.id = tm.TeamId"), sq.Eq{"tm.UserId": userID}, }). - OrderBy("c.DisplayName") + OrderBy("c.DisplayName"). + Limit(model.ChannelSearchDefaultLimit) if !includeDeleted { query = query.Where(sq.And{ diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index 69b9328ec8..baffe97abd 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -115,7 +115,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("GetGuestCount", func(t *testing.T) { testGetGuestCount(t, ss) }) t.Run("SearchMore", func(t *testing.T) { testChannelStoreSearchMore(t, ss) }) t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, ss) }) - t.Run("Autocomplete", func(t *testing.T) { testAutocomplete(t, ss) }) + t.Run("Autocomplete", func(t *testing.T) { testAutocomplete(t, ss, s) }) t.Run("SearchArchivedInTeam", func(t *testing.T) { testChannelStoreSearchArchivedInTeam(t, ss, s) }) t.Run("SearchForUserInTeam", func(t *testing.T) { testChannelStoreSearchForUserInTeam(t, ss) }) t.Run("SearchAllChannels", func(t *testing.T) { testChannelStoreSearchAllChannels(t, ss) }) @@ -5986,7 +5986,7 @@ func testChannelStoreSearchInTeam(t *testing.T, ss store.Store) { } } -func testAutocomplete(t *testing.T, ss store.Store) { +func testAutocomplete(t *testing.T, ss store.Store, s SqlStore) { t1 := &model.Team{ DisplayName: "t1", Name: NewTestId(), @@ -6165,9 +6165,9 @@ func testAutocomplete(t *testing.T, ss store.Store) { } for _, testCase := range testCases { - t.Run("Autocomplete/"+testCase.Description, func(t *testing.T) { - channels, err := ss.Channel().Autocomplete(testCase.UserID, testCase.Term, testCase.IncludeDeleted, testCase.IsGuest) - require.NoError(t, err) + t.Run(testCase.Description, func(t *testing.T) { + channels, err2 := ss.Channel().Autocomplete(testCase.UserID, testCase.Term, testCase.IncludeDeleted, testCase.IsGuest) + require.NoError(t, err2) var gotChannelIds []string var gotTeamNames []string for _, ch := range channels { @@ -6178,6 +6178,24 @@ func testAutocomplete(t *testing.T, ss store.Store) { require.ElementsMatch(t, testCase.ExpectedTeamNames, gotTeamNames, "team names are not as expected") }) } + + t.Run("Limit", func(t *testing.T) { + for i := 0; i < model.ChannelSearchDefaultLimit+10; i++ { + _, err = ss.Channel().Save(&model.Channel{ + TeamId: teamID, + DisplayName: "Channel " + strconv.Itoa(i), + Name: NewTestId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + } + channels, err := ss.Channel().Autocomplete(m1.UserId, "Chann", false, false) + require.NoError(t, err) + assert.Len(t, channels, model.ChannelSearchDefaultLimit) + }) + + // Manually truncate Channels table until testlib can handle cleanups + s.GetMasterX().Exec("TRUNCATE Channels") } func testChannelStoreSearchForUserInTeam(t *testing.T, ss store.Store) { From 273f572cbe7d3bb268c926181cfee991260d1544 Mon Sep 17 00:00:00 2001 From: na Date: Mon, 24 Apr 2023 14:37:53 +0700 Subject: [PATCH 102/113] [MM-51502] - Update email field position in profile popover (#23024) Co-authored-by: Nevyana Angelova --- .../profile_popover/profile_popover.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/webapp/channels/src/components/profile_popover/profile_popover.tsx b/webapp/channels/src/components/profile_popover/profile_popover.tsx index f436ac4e6b..de8d5d753e 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.tsx @@ -524,23 +524,6 @@ class ProfilePopover extends React.PureComponent, ); - const email = this.props.user.email || ''; - if (email && !this.props.user.is_bot && !haveOverrideProp) { - dataContent.push( - , - ); - } if (this.props.user.position && !haveOverrideProp) { const position = (this.props.user?.position || '').substring( 0, @@ -561,6 +544,23 @@ class ProfilePopover extends React.PureComponent, ); + const email = this.props.user.email || ''; + if (email && !this.props.user.is_bot && !haveOverrideProp) { + dataContent.push( + , + ); + } dataContent.push( Date: Mon, 24 Apr 2023 02:04:29 -0600 Subject: [PATCH 103/113] Make channel type filter dropdown a button instead of an anchor (#22827) Automatic Merge --- webapp/channels/src/components/searchable_channel_list.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/searchable_channel_list.jsx b/webapp/channels/src/components/searchable_channel_list.jsx index bd9d4f0e59..fc66227ba7 100644 --- a/webapp/channels/src/components/searchable_channel_list.jsx +++ b/webapp/channels/src/components/searchable_channel_list.jsx @@ -250,10 +250,10 @@ export default class SearchableChannelList extends React.PureComponent { channelDropdown = (
- + Date: Mon, 24 Apr 2023 12:58:33 +0200 Subject: [PATCH 104/113] MM-50963 - enhance onboarding self-hosted telemetry (#23051) Co-authored-by: Mattermost Build --- .../src/components/preparing_workspace/organization.tsx | 6 +++--- .../channels/src/components/preparing_workspace/plugins.tsx | 2 ++ .../components/preparing_workspace/preparing_workspace.tsx | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/preparing_workspace/organization.tsx b/webapp/channels/src/components/preparing_workspace/organization.tsx index 684c6dc4d9..81f5c3f166 100644 --- a/webapp/channels/src/components/preparing_workspace/organization.tsx +++ b/webapp/channels/src/components/preparing_workspace/organization.tsx @@ -38,8 +38,8 @@ type Props = PreparingWorkspacePageProps & { setInviteId: (inviteId: string) => void; } -const reportValidationError = debounce(() => { - trackEvent('first_admin_setup', 'validate_organization_error'); +const reportValidationError = debounce((error: string) => { + trackEvent('first_admin_setup', 'admin_onboarding_organization_submit_fail', {error}); }, 700, {leading: false}); const Organization = (props: Props) => { @@ -123,7 +123,7 @@ const Organization = (props: Props) => { } if (validation.error || teamApiError.current) { - reportValidationError(); + reportValidationError(validation.error ? validation.error : teamApiError.current! as string); return; } props.next?.(); diff --git a/webapp/channels/src/components/preparing_workspace/plugins.tsx b/webapp/channels/src/components/preparing_workspace/plugins.tsx index caf04e794e..93855aa0b0 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.tsx +++ b/webapp/channels/src/components/preparing_workspace/plugins.tsx @@ -31,6 +31,7 @@ type Props = PreparingWorkspacePageProps & { setOption: (option: keyof Form['plugins']) => void; className?: string; isSelfHosted: boolean; + handleVisitMarketPlaceClick: () => void; } const Plugins = (props: Props) => { const {formatMessage} = useIntl(); @@ -178,6 +179,7 @@ const Plugins = (props: Props) => { {chunks} diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx index 268e21c55e..4e0d5dc9aa 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx @@ -267,6 +267,7 @@ const PreparingWorkspace = (props: Props) => { const goToChannels = () => { dispatch({type: GeneralTypes.SHOW_LAUNCHING_WORKSPACE, open: true}); props.history.push(`/${team.name}/channels${Constants.DEFAULT_CHANNEL}`); + trackEvent('first_admin_setup', 'admin_setup_complete'); }; const sendFormEnd = Date.now(); @@ -460,6 +461,9 @@ const PreparingWorkspace = (props: Props) => { show={shouldShowPage(WizardSteps.Plugins)} transitionDirection={getTransitionDirection(WizardSteps.Plugins)} className='child-page' + handleVisitMarketPlaceClick={() => { + trackEvent('first_admin_setup', 'click_visit_marketplace_link'); + }} /> Date: Mon, 24 Apr 2023 10:34:31 -0300 Subject: [PATCH 105/113] Absolute paths in Playbooks API Spec (#22989) --- server/playbooks/server/api/api.yaml | 48 ++++++++++++++-------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/server/playbooks/server/api/api.yaml b/server/playbooks/server/api/api.yaml index 538c03ca79..bed383530c 100644 --- a/server/playbooks/server/api/api.yaml +++ b/server/playbooks/server/api/api.yaml @@ -10,7 +10,7 @@ info: servers: - url: http://localhost:8065/plugins/playbooks/api/v0 paths: - /runs: + /plugins/playbooks/api/v0/runs: get: summary: List all playbook runs description: Retrieve a paged list of playbook runs, filtered by team, status, owner, name and/or members, and sorted by ID, name, status, creation date, end date, team or owner ID. @@ -198,7 +198,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/dialog: + /plugins/playbooks/api/v0/runs/dialog: post: summary: Create a new playbook run from dialog description: This is an internal endpoint to create a playbook run from the submission of an interactive dialog, filled by a user in the webapp. See [Interactive Dialogs](https://docs.mattermost.com/developer/interactive-dialogs.html) for more information. @@ -276,7 +276,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/owners: + /plugins/playbooks/api/v0/runs/owners: get: summary: Get all owners description: Get the owners of all playbook runs, filtered by team. @@ -314,7 +314,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/channels: + /plugins/playbooks/api/v0/runs/channels: get: summary: Get playbook run channels description: Get all channels associated with a playbook run, filtered by team, status, owner, name and/or members, and sorted by ID, name, status, creation date, end date, team, or owner ID. @@ -413,7 +413,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/checklist-autocomplete: + /plugins/playbooks/api/v0/runs/checklist-autocomplete: get: summary: Get autocomplete data for /playbook check description: This is an internal endpoint used by the autocomplete system to retrieve the data needed to show the list of items that the user can check. @@ -459,7 +459,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/channel/{channel_id}: + /plugins/playbooks/api/v0/runs/channel/{channel_id}: get: summary: Find playbook run by channel ID operationId: getPlaybookRunByChannelId @@ -492,7 +492,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}: + /plugins/playbooks/api/v0/runs/{id}: get: summary: Get a playbook run operationId: getPlaybookRun @@ -565,7 +565,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/metadata: + /plugins/playbooks/api/v0/runs/{id}/metadata: get: summary: Get playbook run metadata operationId: getPlaybookRunMetadata @@ -598,7 +598,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/end: + /plugins/playbooks/api/v0/runs/{id}/end: put: summary: End a playbook run operationId: endPlaybookRun @@ -651,7 +651,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/restart: + /plugins/playbooks/api/v0/runs/{id}/restart: put: summary: Restart a playbook run operationId: restartPlaybookRun @@ -678,7 +678,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/status: + /plugins/playbooks/api/v0/runs/{id}/status: post: summary: Update a playbook run's status operationId: status @@ -728,7 +728,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/finish: + /plugins/playbooks/api/v0/runs/{id}/finish: put: summary: Finish a playbook operationId: finish @@ -755,7 +755,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/owner: + /plugins/playbooks/api/v0/runs/{id}/owner: post: summary: Update playbook run owner operationId: changeOwner @@ -800,7 +800,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/next-stage-dialog: + /plugins/playbooks/api/v0/runs/{id}/next-stage-dialog: post: summary: Go to next stage from dialog description: This is an internal endpoint to go to the next stage via a confirmation dialog, submitted by a user in the webapp. @@ -835,7 +835,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/checklists/{checklist}/add: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/add: put: summary: Add an item to a playbook run's checklist description: The most common pattern to add a new item is to only send its title as the request payload. By default, it is an open item, with no assignee and no slash command. @@ -923,7 +923,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /runs/{id}/checklists/{checklist}/reorder: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/reorder: put: summary: Reorder an item in a playbook run's checklist operationId: reoderChecklistItem @@ -978,7 +978,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/checklists/{checklist}/item/{item}: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}: put: summary: Update an item of a playbook run's checklist description: Update the title and the slash command of an item in one of the playbook run's checklists. @@ -1083,7 +1083,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/checklists/{checklist}/item/{item}/state: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/state: put: summary: Update the state of an item operationId: itemSetState @@ -1145,7 +1145,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/checklists/{checklist}/item/{item}/assignee: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/assignee: put: summary: Update the assignee of an item operationId: itemSetAssignee @@ -1202,7 +1202,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/checklists/{checklist}/item/{item}/run: + /plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/run: put: summary: Run an item's slash command operationId: itemRun @@ -1249,7 +1249,7 @@ paths: 500: $ref: "#/components/responses/500" - /runs/{id}/timeline/{event_id}/: + /plugins/playbooks/api/v0/runs/{id}/timeline/{event_id}/: delete: summary: Remove a timeline event from the playbook run operationId: removeTimelineEvent @@ -1285,7 +1285,7 @@ paths: 500: $ref: "#/components/responses/500" - /playbooks: + /plugins/playbooks/api/v0/playbooks: get: summary: List all playbooks description: Retrieve a paged list of playbooks, filtered by team, and sorted by title, number of stages or number of steps. @@ -1562,7 +1562,7 @@ paths: 500: $ref: "#/components/responses/500" - /playbooks/{id}: + /plugins/playbooks/api/v0/playbooks/{id}: get: summary: Get a playbook operationId: getPlaybook @@ -1658,7 +1658,7 @@ paths: 500: $ref: "#/components/responses/500" - /playbooks/{id}/autofollows: + /plugins/playbooks/api/v0/playbooks/{id}/autofollows: get: summary: Get the list of followers' user IDs of a playbook operationId: getAutoFollows From 2dc55918c7d3e7f3126d5891267d901c46238eb4 Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Mon, 24 Apr 2023 16:37:22 +0300 Subject: [PATCH 106/113] [MM-52287] - Cloud Free should not show the ability to start a trial (#23073) * [MM-52287] - Cloud Free should not show the ability to start a trial * fix logic --- .../admin_console/billing/billing_summary/index.tsx | 12 ++++++++---- .../src/selectors/entities/preferences.ts | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/index.tsx b/webapp/channels/src/components/admin_console/billing/billing_summary/index.tsx index e53bd72570..8f0d5c5c56 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/index.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/index.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {useSelector} from 'react-redux'; import {getSubscriptionProduct, checkHadPriorTrial, getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; +import {cloudReverseTrial} from 'mattermost-redux/selectors/entities/preferences'; import {CloudProducts} from 'utils/constants'; @@ -27,17 +28,20 @@ type BillingSummaryProps = { const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}: BillingSummaryProps) => { const subscription = useSelector(getCloudSubscription); const product = useSelector(getSubscriptionProduct); + const reverseTrial = useSelector(cloudReverseTrial); let body = noBillingHistory; const isPreTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at === 0; const hasPriorTrial = useSelector(checkHadPriorTrial); - const showTryEnterprise = product?.sku === CloudProducts.STARTER && isPreTrial; - const showUpgradeProfessional = product?.sku === CloudProducts.STARTER && hasPriorTrial; + const isStarterPreTrial = product?.sku === CloudProducts.STARTER && isPreTrial; + const isStarterPostTrial = product?.sku === CloudProducts.STARTER && hasPriorTrial; - if (showTryEnterprise) { + if (isStarterPreTrial && reverseTrial) { + body = ; + } else if (isStarterPreTrial) { body = tryEnterpriseCard; - } else if (showUpgradeProfessional) { + } else if (isStarterPostTrial) { body = ; } else if (isFreeTrial) { body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index b2d3c1a672..b3f5d1f843 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -300,6 +300,10 @@ export function deprecateCloudFree(state: GlobalState): boolean { return getFeatureFlagValue(state, 'DeprecateCloudFree') === 'true'; } +export function cloudReverseTrial(state: GlobalState): boolean { + return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true'; +} + export function appsSidebarCategoryEnabled(state: GlobalState): boolean { return getFeatureFlagValue(state, 'AppsSidebarCategory') === 'true'; } From bf4555a223d8cc2a839dfe680ac7639664b54689 Mon Sep 17 00:00:00 2001 From: Spiros Economakis <812075+spirosoik@users.noreply.github.com> Date: Mon, 24 Apr 2023 18:36:51 +0300 Subject: [PATCH 107/113] Bump libucrl gnutls to `7.64.0-4+deb10u6` (#23084) The pin to previous version fails with not found. Ticket: https://mattermost.atlassian.net/browse/CLD-5582 --- server/build/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/build/Dockerfile b/server/build/Dockerfile index 294debc3f9..2db70884b0 100644 --- a/server/build/Dockerfile +++ b/server/build/Dockerfile @@ -58,7 +58,7 @@ RUN apt-get update \ libxext6=2:1.3.3-1+b2 \ libxrender1=1:0.9.10-1 \ libcairo2=1.16.0-4+deb10u1 \ - libcurl3-gnutls=7.64.0-4+deb10u5 \ + libcurl3-gnutls=7.64.0-4+deb10u6 \ libglib2.0-0=2.58.3-2+deb10u3 \ libgsf-1-common=1.14.45-1 \ libgsf-1-114=1.14.45-1 \ From 94cb2867a7ebea1378a87ffb4d2c8574a39d1152 Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Mon, 24 Apr 2023 09:59:01 -0700 Subject: [PATCH 108/113] [MM-52271] Add recommended tag to Work Template integrations (#23016) --- .../worktemplates/generator/worktemplate.tmpl | 1 + .../channels/app/worktemplates/templates.yaml | 12 ++++++-- server/channels/app/worktemplates/types.go | 6 ++-- .../worktemplates/worktemplate_generated.go | 30 ++++++++++++------- server/model/worktemplate.go | 3 +- .../work_templates/components/preview.tsx | 2 +- .../src/components/work_templates/index.tsx | 18 +++++++++-- webapp/platform/types/src/work_templates.ts | 1 + 8 files changed, 55 insertions(+), 18 deletions(-) diff --git a/server/channels/app/worktemplates/generator/worktemplate.tmpl b/server/channels/app/worktemplates/generator/worktemplate.tmpl index 60f40b5301..52d8d0be5f 100644 --- a/server/channels/app/worktemplates/generator/worktemplate.tmpl +++ b/server/channels/app/worktemplates/generator/worktemplate.tmpl @@ -93,6 +93,7 @@ var wt{{.MD5}} = &WorkTemplate{ Illustration: "{{.Playbook.Illustration}}", },{{end}}{{if .Integration}}Integration: &Integration{ ID: "{{.Integration.ID}}", + Recommended: {{.Integration.Recommended}}, },{{end}} }, {{end}} diff --git a/server/channels/app/worktemplates/templates.yaml b/server/channels/app/worktemplates/templates.yaml index 351e034d15..362b014a71 100644 --- a/server/channels/app/worktemplates/templates.yaml +++ b/server/channels/app/worktemplates/templates.yaml @@ -45,8 +45,10 @@ content: illustration: "/static/worktemplates/playbooks/product_release.png" - integration: id: jira + recommended: true - integration: id: github + recommended: true --- id: 'product_teams/goals_and_okrs:v1' category: product_teams @@ -86,7 +88,7 @@ content: channel: channel-1674845108569 - integration: id: zoom - + recommended: true --- id: 'product_teams/bug_bash:v1' category: product_teams @@ -120,6 +122,7 @@ content: playbook: playbook-1674844017943 - integration: id: jira + recommended: true --- id: 'product_teams/sprint_planning:v1' category: product_teams @@ -153,6 +156,7 @@ content: channel: channel-1674850783500 - integration: id: zoom + recommended: true --- id: 'product_teams/product_roadmap:v1' category: product_teams @@ -282,6 +286,7 @@ content: channel: channel-1674845108569 - integration: id: zoom + recommended: true --- id: 'companywide/create_project:v1' category: companywide @@ -316,10 +321,13 @@ content: channel: channel-1674851940114 - integration: id: jira + recommended: true - integration: id: github + recommended: true - integration: id: zoom + recommended: true --- ###################### # Leadership @@ -356,4 +364,4 @@ content: channel: channel-1674845108569 - integration: id: zoom - + recommended: true diff --git a/server/channels/app/worktemplates/types.go b/server/channels/app/worktemplates/types.go index a75db605aa..83649a4571 100644 --- a/server/channels/app/worktemplates/types.go +++ b/server/channels/app/worktemplates/types.go @@ -108,7 +108,8 @@ func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemp if content.Integration != nil { mwt.Content = append(mwt.Content, model.WorkTemplateContent{ Integration: &model.WorkTemplateIntegration{ - ID: content.Integration.ID, + ID: content.Integration.ID, + Recommended: content.Integration.Recommended, }, }) } @@ -320,7 +321,8 @@ func (p *Playbook) Validate() error { } type Integration struct { - ID string `yaml:"id"` + ID string `yaml:"id"` + Recommended bool `yaml:"recommended"` } func (i *Integration) Validate() error { diff --git a/server/channels/app/worktemplates/worktemplate_generated.go b/server/channels/app/worktemplates/worktemplate_generated.go index a201d7c5c3..f7e3a3e16f 100644 --- a/server/channels/app/worktemplates/worktemplate_generated.go +++ b/server/channels/app/worktemplates/worktemplate_generated.go @@ -148,12 +148,14 @@ var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{ }, { Integration: &Integration{ - ID: "jira", + ID: "jira", + Recommended: true, }, }, { Integration: &Integration{ - ID: "github", + ID: "github", + Recommended: true, }, }, }, @@ -214,7 +216,8 @@ var wt5baa68055bf9ea423273662e01ccc575 = &WorkTemplate{ }, { Integration: &Integration{ - ID: "zoom", + ID: "zoom", + Recommended: true, }, }, }, @@ -265,7 +268,8 @@ var wtfeb56bc6a8f277c47b503bd1c92d830e = &WorkTemplate{ }, { Integration: &Integration{ - ID: "jira", + ID: "jira", + Recommended: true, }, }, }, @@ -317,7 +321,8 @@ var wt8d2ef53deac5517eb349dc5de6150196 = &WorkTemplate{ }, { Integration: &Integration{ - ID: "zoom", + ID: "zoom", + Recommended: true, }, }, }, @@ -518,7 +523,8 @@ var wtf7b846d35810f8272eeb9a1a562025b5 = &WorkTemplate{ }, { Integration: &Integration{ - ID: "zoom", + ID: "zoom", + Recommended: true, }, }, }, @@ -570,17 +576,20 @@ var wtb9ab412890c2410c7b49eec8f12e7edc = &WorkTemplate{ }, { Integration: &Integration{ - ID: "jira", + ID: "jira", + Recommended: true, }, }, { Integration: &Integration{ - ID: "github", + ID: "github", + Recommended: true, }, }, { Integration: &Integration{ - ID: "zoom", + ID: "zoom", + Recommended: true, }, }, }, @@ -632,7 +641,8 @@ var wt32ab773bfe021e3d4913931041552559 = &WorkTemplate{ }, { Integration: &Integration{ - ID: "zoom", + ID: "zoom", + Recommended: true, }, }, }, diff --git a/server/model/worktemplate.go b/server/model/worktemplate.go index b0c4262784..73857524bb 100644 --- a/server/model/worktemplate.go +++ b/server/model/worktemplate.go @@ -69,7 +69,8 @@ type WorkTemplatePlaybook struct { } type WorkTemplateIntegration struct { - ID string `json:"id"` + ID string `json:"id"` + Recommended bool `json:"recommended"` } type WorkTemplateContent struct { diff --git a/webapp/channels/src/components/work_templates/components/preview.tsx b/webapp/channels/src/components/work_templates/components/preview.tsx index 14ea11265d..30667461f6 100644 --- a/webapp/channels/src/components/work_templates/components/preview.tsx +++ b/webapp/channels/src/components/work_templates/components/preview.tsx @@ -117,7 +117,7 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { if (c.playbook) { playbooks.push(c.playbook); } - if (c.integration) { + if (c.integration && c.integration.recommended) { availableIntegrations.push(c.integration); } }); diff --git a/webapp/channels/src/components/work_templates/index.tsx b/webapp/channels/src/components/work_templates/index.tsx index ed5eb0d6a6..d086c34525 100644 --- a/webapp/channels/src/components/work_templates/index.tsx +++ b/webapp/channels/src/components/work_templates/index.tsx @@ -232,7 +232,12 @@ const WorkTemplateModal = () => { const execute = async (template: WorkTemplate, name = '', visibility: Visibility) => { const pbTemplates = []; - for (const item of template.content) { + for (const ctt in template.content) { + if (!Object.hasOwn(template.content, ctt)) { + continue; + } + + const item = template.content[ctt]; if (item.playbook) { const pbTemplate = playbookTemplates.find((pb) => pb.title === item.playbook.template); if (pbTemplate) { @@ -241,11 +246,20 @@ const WorkTemplateModal = () => { } } + // remove non recommended integrations + const filteredTemplate = {...template}; + filteredTemplate.content = template.content.filter((item) => { + if (!item.integration) { + return true; + } + return item.integration.recommended; + }); + const req: ExecuteWorkTemplateRequest = { team_id: teamId, name, visibility, - work_template: template, + work_template: filteredTemplate, playbook_templates: pbTemplates, }; diff --git a/webapp/platform/types/src/work_templates.ts b/webapp/platform/types/src/work_templates.ts index d9d776e566..8b18abf958 100644 --- a/webapp/platform/types/src/work_templates.ts +++ b/webapp/platform/types/src/work_templates.ts @@ -64,6 +64,7 @@ export interface Playbook { } export interface Integration { id: string; + recommended: boolean; name?: string; icon?: string; installed?: boolean; From a52a6d9abdcedc8a1b71b737f89782a69a602dae Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Mon, 24 Apr 2023 14:46:16 -0600 Subject: [PATCH 109/113] Fixes for admins when LastBoardID doesn't exist (#22993) * fixes for admins when LastBoardID doesn't exist * lint fixes * Update webapp/boards/src/components/sidebar/sidebarCategory.tsx Co-authored-by: Caleb Roseland * after deletion, if no boards, send to team (template selector) --------- Co-authored-by: Caleb Roseland Co-authored-by: Mattermost Build --- .../src/components/sidebar/sidebarCategory.tsx | 13 +++++++++++++ webapp/boards/src/pages/boardPage/boardPage.tsx | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/webapp/boards/src/components/sidebar/sidebarCategory.tsx b/webapp/boards/src/components/sidebar/sidebarCategory.tsx index 5b54c8c7a9..734490c9ed 100644 --- a/webapp/boards/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/boards/src/components/sidebar/sidebarCategory.tsx @@ -25,6 +25,7 @@ import CompassIcon from 'src/widgets/icons/compassIcon' import OptionsIcon from 'src/widgets/icons/options' import Menu from 'src/widgets/menu' import MenuWrapper from 'src/widgets/menuWrapper' +import {UserSettings} from 'src/userSettings' import './sidebarCategory.scss' import {Category, CategoryBoardMetadata, CategoryBoards} from 'src/store/sidebar' @@ -202,12 +203,24 @@ const SidebarCategory = (props: Props) => { setTimeout(() => { showBoard(props.boards[nextBoardId as number].id) }, 120) + } else { + setTimeout(() => { + const newPath = generatePath('/team/:teamId', {teamId: teamID,}) + history.push(newPath) + }, 120) } }, async () => { showBoard(deleteBoard.id) }, ) + if ( + UserSettings.lastBoardId && + UserSettings.lastBoardId[deleteBoard.teamId] == deleteBoard.id + ) { + UserSettings.setLastBoardID(deleteBoard.teamId, null) + UserSettings.setLastViewId(deleteBoard.id, null) + } }, [showBoard, deleteBoard, props.boards]) const updateCategory = useCallback(async (value: boolean) => { diff --git a/webapp/boards/src/pages/boardPage/boardPage.tsx b/webapp/boards/src/pages/boardPage/boardPage.tsx index 4f7fc321ba..1bce774cb7 100644 --- a/webapp/boards/src/pages/boardPage/boardPage.tsx +++ b/webapp/boards/src/pages/boardPage/boardPage.tsx @@ -186,7 +186,9 @@ const BoardPage = (props: Props): JSX.Element => { const joinBoard = async (myUser: IUser, boardTeamId: string, boardId: string, allowAdmin: boolean) => { const member = await octoClient.joinBoard(boardId, allowAdmin) if (!member) { - if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) { + // if allowAdmin is true, then we failed to join the board + // as an admin, normally, this is deleted/missing board + if (!allowAdmin && myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) { setShowJoinBoardDialog(true) return } From e8915b318284f345eadb256a3e731e18fc00bada Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Tue, 25 Apr 2023 00:02:51 +0200 Subject: [PATCH 110/113] [MM-45296] Fix installation of pre-packaged plugins that are not in the Marketplace (#21895) Co-authored-by: Jesse Hallam --- server/channels/api4/plugin_test.go | 158 +++++++++++++++++--------- server/channels/app/plugin_install.go | 49 ++++---- 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/server/channels/api4/plugin_test.go b/server/channels/api4/plugin_test.go index 98ff5ac3f1..8a923d1d0c 100644 --- a/server/channels/api4/plugin_test.go +++ b/server/channels/api4/plugin_test.go @@ -1722,14 +1722,26 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) { appErr := th.App.AddPublicKey("pub_key", key) require.Nil(t, appErr) + t.Cleanup(func() { + appErr = th.App.DeletePublicKey("pub_key") + require.Nil(t, appErr) + }) + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { serverVersion := req.URL.Query().Get("server_version") require.NotEmpty(t, serverVersion) require.Equal(t, model.CurrentVersion, serverVersion) res.WriteHeader(http.StatusOK) + var out []byte - out, err = json.Marshal([]*model.MarketplacePlugin{samplePlugins[1]}) - require.NoError(t, err) + + // Return something if testplugin2 or no specific plugin is requested + pluginID := req.URL.Query().Get("plugin_id") + if pluginID == "" || pluginID == samplePlugins[1].Manifest.Id { + out, err = json.Marshal([]*model.MarketplacePlugin{samplePlugins[1]}) + require.NoError(t, err) + } + res.Write(out) })) defer testServer.Close() @@ -1748,43 +1760,52 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) { require.Len(t, pluginsResp.Active, 0) require.Len(t, pluginsResp.Inactive, 0) - // Should fail to install unknown prepackaged plugin - pRequest := &model.InstallMarketplacePluginRequest{Id: "testpluginXX"} - manifest, resp, err := client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) + t.Run("Should fail to install unknown prepackaged plugin", func(t *testing.T) { + pRequest := &model.InstallMarketplacePluginRequest{Id: "testpluginXX"} + manifest, resp, err := client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) - plugins := env.PrepackagedPlugins() - require.Len(t, plugins, 1) - require.Equal(t, "testplugin", plugins[0].Manifest.Id) - require.Equal(t, pluginSignatureData, plugins[0].Signature) + plugins := env.PrepackagedPlugins() + require.Len(t, plugins, 1) + require.Equal(t, "testplugin", plugins[0].Manifest.Id) + require.Equal(t, pluginSignatureData, plugins[0].Signature) - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + }) - pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin"} - manifest1, _, err := client.InstallMarketplacePlugin(pRequest) - require.NoError(t, err) - require.NotNil(t, manifest1) - require.Equal(t, "testplugin", manifest1.Id) - require.Equal(t, "0.0.1", manifest1.Version) + t.Run("Install prepackaged plugin with Marketplace disabled", func(t *testing.T) { + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} + manifest, _, err := client.InstallMarketplacePlugin(pRequest) + require.NoError(t, err) + require.NotNil(t, manifest) + require.Equal(t, "testplugin", manifest.Id) + require.Equal(t, "0.0.1", manifest.Version) - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Equal(t, pluginsResp.Inactive, []*model.PluginInfo{{ - Manifest: *manifest1, - }}) + t.Cleanup(func() { + _, err = client.RemovePlugin(manifest.Id) + require.NoError(t, err) + }) - // Try to install remote marketplace plugin - pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} - manifest, resp, err = client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Equal(t, pluginsResp.Inactive, []*model.PluginInfo{{ + Manifest: *manifest, + }}) + }) + + t.Run("Try to install remote marketplace plugin while Marketplace is disabled", func(t *testing.T) { + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin2"} + manifest, resp, err := client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) + }) // Enable remote marketplace th.App.UpdateConfig(func(cfg *model.Config) { @@ -1794,31 +1815,58 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) { *cfg.PluginSettings.AllowInsecureDownloadURL = true }) - pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} - manifest2, _, err := client.InstallMarketplacePlugin(pRequest) - require.NoError(t, err) - require.NotNil(t, manifest2) - require.Equal(t, "testplugin2", manifest2.Id) - require.Equal(t, "1.2.3", manifest2.Version) + t.Run("Install prepackaged, not listed plugin with Marketplace enabled", func(t *testing.T) { + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} + manifest, _, err := client.InstallMarketplacePlugin(pRequest) + require.NoError(t, err) - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.ElementsMatch(t, pluginsResp.Inactive, []*model.PluginInfo{ - { - Manifest: *manifest1, - }, - { - Manifest: *manifest2, - }, + t.Cleanup(func() { + _, err = client.RemovePlugin(manifest.Id) + require.NoError(t, err) + }) + + require.NotNil(t, manifest) + assert.Equal(t, "testplugin", manifest.Id) + assert.Equal(t, "0.0.1", manifest.Version) }) - // Clean up - _, err = client.RemovePlugin(manifest1.Id) - require.NoError(t, err) + t.Run("Install both a prepacked and a Marketplace plugin", func(t *testing.T) { + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} + manifest1, _, err := client.InstallMarketplacePlugin(pRequest) + require.NoError(t, err) + require.NotNil(t, manifest1) + assert.Equal(t, "testplugin", manifest1.Id) + assert.Equal(t, "0.0.1", manifest1.Version) - _, err = client.RemovePlugin(manifest2.Id) - require.NoError(t, err) + t.Cleanup(func() { + _, err = client.RemovePlugin(manifest1.Id) + require.NoError(t, err) + }) + + pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} + manifest2, _, err := client.InstallMarketplacePlugin(pRequest) + require.NoError(t, err) + require.NotNil(t, manifest2) + require.Equal(t, "testplugin2", manifest2.Id) + require.Equal(t, "1.2.3", manifest2.Version) + + t.Cleanup(func() { + _, err = client.RemovePlugin(manifest2.Id) + require.NoError(t, err) + }) + + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.ElementsMatch(t, pluginsResp.Inactive, []*model.PluginInfo{ + { + Manifest: *manifest1, + }, + { + Manifest: *manifest2, + }, + }) + }) appErr = th.App.DeletePublicKey("pub_key") require.Nil(t, appErr) diff --git a/server/channels/app/plugin_install.go b/server/channels/app/plugin_install.go index de40c6838a..59ae9be613 100644 --- a/server/channels/app/plugin_install.go +++ b/server/channels/app/plugin_install.go @@ -203,35 +203,38 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace { var plugin *model.BaseMarketplacePlugin plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version) - if appErr != nil { - return nil, appErr + // The plugin might only be prepackaged and not on the Marketplace. + if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" { + mlog.Warn("Failed to reach Marketplace to install plugin", mlog.String("plugin_id", request.Id), mlog.Err(appErr)) } - var prepackagedVersion semver.Version - if prepackagedPlugin != nil { - var err error - prepackagedVersion, err = semver.Parse(prepackagedPlugin.Manifest.Version) - if err != nil { - return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err) + if plugin != nil { + var prepackagedVersion semver.Version + if prepackagedPlugin != nil { + var err error + prepackagedVersion, err = semver.Parse(prepackagedPlugin.Manifest.Version) + if err != nil { + return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } } - } - marketplaceVersion, err := semver.Parse(plugin.Manifest.Version) - if err != nil { - return nil, model.NewAppError("InstallMarketplacePlugin", "app.prepackged-plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err) - } + marketplaceVersion, err := semver.Parse(plugin.Manifest.Version) + if err != nil { + return nil, model.NewAppError("InstallMarketplacePlugin", "app.prepackged-plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } - if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found - downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL) - if err != nil { - return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found + downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL) + if err != nil { + return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + signature, err := plugin.DecodeSignature() + if err != nil { + return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.signature_decode.app_error", nil, "", http.StatusNotImplemented).Wrap(err) + } + pluginFile = bytes.NewReader(downloadedPluginBytes) + signatureFile = signature } - signature, err := plugin.DecodeSignature() - if err != nil { - return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.signature_decode.app_error", nil, "", http.StatusNotImplemented).Wrap(err) - } - pluginFile = bytes.NewReader(downloadedPluginBytes) - signatureFile = signature } } From f9836ee26a299ba0a3e690b04ab65739c5922fe9 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Tue, 25 Apr 2023 00:04:17 +0200 Subject: [PATCH 111/113] [MM-51274] Remove deprecated PermissionUseSlashCommands (#22819) Co-authored-by: Mattermost Build --- server/channels/api4/command.go | 14 -------------- server/channels/app/app_test.go | 1 - server/channels/app/import_functions_test.go | 2 +- server/channels/app/permissions_test.go | 6 +++--- .../testlib/testdata/mysql_migration_warmup.sql | 6 +++--- .../testlib/testdata/postgres_migration_warmup.sql | 6 +++--- server/model/permission.go | 11 ----------- server/model/role.go | 2 -- server/model/role_test.go | 1 - .../mattermost-redux/src/constants/permissions.ts | 1 - webapp/channels/src/utils/constants.tsx | 3 --- 11 files changed, 10 insertions(+), 43 deletions(-) diff --git a/server/channels/api4/command.go b/server/channels/api4/command.go index 312c0d093f..724994fe01 100644 --- a/server/channels/api4/command.go +++ b/server/channels/api4/command.go @@ -329,13 +329,6 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) { return } - // For compatibility reasons, PermissionCreatePost is also checked. - // TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionUseSlashCommands) { - c.SetPermissionError(model.PermissionUseSlashCommands) - return - } - channel, err := c.App.GetChannel(c.AppContext, commandArgs.ChannelId) if err != nil { c.Err = err @@ -354,13 +347,6 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionCreatePost) return } - - // For compatibility reasons, PermissionCreatePost is also checked. - // TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionUseSlashCommands) { - c.SetPermissionError(model.PermissionUseSlashCommands) - return - } } } diff --git a/server/channels/app/app_test.go b/server/channels/app/app_test.go index 22f221d47b..0ba8caff88 100644 --- a/server/channels/app/app_test.go +++ b/server/channels/app/app_test.go @@ -119,7 +119,6 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) { model.PermissionGetPublicLink.Id, model.PermissionCreatePost.Id, model.PermissionUseChannelMentions.Id, - model.PermissionUseSlashCommands.Id, model.PermissionManagePublicChannelProperties.Id, model.PermissionDeletePublicChannel.Id, model.PermissionManagePrivateChannelProperties.Id, diff --git a/server/channels/app/import_functions_test.go b/server/channels/app/import_functions_test.go index 95eb6ec64d..d540783603 100644 --- a/server/channels/app/import_functions_test.go +++ b/server/channels/app/import_functions_test.go @@ -459,7 +459,7 @@ func TestImportImportRole(t *testing.T) { // Try changing all the params and reimporting. data.DisplayName = ptrStr("new display name") data.Description = ptrStr("description") - data.Permissions = &[]string{"use_slash_commands"} + data.Permissions = &[]string{"manage_slash_commands"} err = th.App.importRole(th.Context, &data, false, true) require.Nil(t, err, "Should have succeeded. %v", err) diff --git a/server/channels/app/permissions_test.go b/server/channels/app/permissions_test.go index 9ae52a1605..37ced9bd92 100644 --- a/server/channels/app/permissions_test.go +++ b/server/channels/app/permissions_test.go @@ -114,7 +114,7 @@ func TestImportPermissions(t *testing.T) { } beforeCount = len(results) - json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) + json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) r := strings.NewReader(json) err := th.App.ImportPermissions(r) @@ -183,7 +183,7 @@ func TestImportPermissions_idempotentScheme(t *testing.T) { roleName1 := model.NewId() roleName2 := model.NewId() - json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) + json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) jsonl := strings.Repeat(json+"\n", 4) r := strings.NewReader(jsonl) @@ -226,7 +226,7 @@ func TestImportPermissions_schemeDeletedOnRoleFailure(t *testing.T) { roleName1 := model.NewId() roleName2 := model.NewId() - jsonl := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) + jsonl := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2) r := strings.NewReader(jsonl) var results []*model.Scheme diff --git a/server/channels/testlib/testdata/mysql_migration_warmup.sql b/server/channels/testlib/testdata/mysql_migration_warmup.sql index 070dae56f6..eaafb2d368 100644 --- a/server/channels/testlib/testdata/mysql_migration_warmup.sql +++ b/server/channels/testlib/testdata/mysql_migration_warmup.sql @@ -81,14 +81,14 @@ INSERT INTO `Roles` VALUES ('hkcrew7wttb5fbuw3ime6g7nzc','system_read_only_admin INSERT INTO `Roles` VALUES ('iiwt9pt6wiyb9e1enixtxs5yme','run_admin','authentication.roles.run_admin.name','authentication.roles.run_admin.description',1662271985864,1662271986932,0,' run_manage_properties run_manage_members',1,1); INSERT INTO `Roles` VALUES ('jg1f1xfh3bb73pua938orwg9ie','system_guest','authentication.roles.global_guest.name','authentication.roles.global_guest.description',1605167829015,1662271986937,0,' create_direct_channel create_group_channel',1,1); INSERT INTO `Roles` VALUES ('k891n5tpd3n9peue79azejjocy','system_post_all_public','authentication.roles.system_post_all_public.name','authentication.roles.system_post_all_public.description',0,1662271986941,0,' use_channel_mentions create_post_public',0,1); -INSERT INTO `Roles` VALUES ('kb6r9i58x7dxdb3srfohd66sse','system_admin','authentication.roles.global_admin.name','authentication.roles.global_admin.description',0,1662271986948,0,' list_public_teams edit_brand manage_private_channel_properties sysconsole_read_user_management_teams playbook_public_create manage_others_bots invalidate_caches manage_shared_channels sysconsole_write_environment_logging manage_others_outgoing_webhooks sysconsole_read_reporting_team_statistics sysconsole_read_plugins list_team_channels use_group_mentions sysconsole_read_site_users_and_teams sysconsole_write_site_localization get_analytics sysconsole_read_experimental_bleve manage_team_roles sysconsole_read_site_localization use_slash_commands edit_post sysconsole_write_user_management_channels test_elasticsearch list_private_teams add_ldap_public_cert join_public_teams manage_slash_commands manage_others_incoming_webhooks manage_public_channel_members sysconsole_read_environment_elasticsearch sysconsole_write_site_customization delete_others_emojis run_manage_members create_emojis sysconsole_write_authentication_email sysconsole_write_compliance_compliance_export add_saml_private_cert create_bot sysconsole_write_environment_rate_limiting add_saml_public_cert edit_other_users sysconsole_write_integrations_integration_management read_user_access_token create_elasticsearch_post_indexing_job sysconsole_write_user_management_users assign_system_admin_role sysconsole_write_user_management_groups sysconsole_read_authentication_guest_access sysconsole_write_about_edition_and_license sysconsole_read_authentication_ldap sysconsole_read_experimental_feature_flags sysconsole_read_integrations_cors sysconsole_read_user_management_groups join_public_channels sysconsole_read_experimental_features test_ldap sysconsole_write_environment_elasticsearch sysconsole_write_reporting_server_logs sysconsole_read_environment_image_proxy sysconsole_read_site_announcement_banner sysconsole_read_reporting_site_statistics sysconsole_write_authentication_mfa sysconsole_read_authentication_openid purge_bleve_indexes playbook_public_manage_members delete_emojis sysconsole_write_environment_file_storage sysconsole_write_reporting_site_statistics playbook_private_manage_members import_team sysconsole_write_environment_web_server sysconsole_write_authentication_password read_public_channel_groups create_compliance_export_job sysconsole_read_authentication_password list_users_without_team sysconsole_read_authentication_mfa add_ldap_private_cert create_data_retention_job read_license_information sysconsole_write_authentication_signup sysconsole_read_environment_push_notification_server edit_others_posts download_compliance_export_result create_ldap_sync_job sysconsole_write_authentication_ldap sysconsole_write_plugins read_data_retention_job sysconsole_write_compliance_data_retention_policy sysconsole_read_site_public_links manage_bots manage_system sysconsole_write_compliance_custom_terms_of_service playbook_public_manage_roles playbook_public_manage_properties playbook_private_create sysconsole_write_experimental_bleve sysconsole_read_authentication_email promote_guest get_saml_cert_status add_user_to_team sysconsole_write_site_users_and_teams create_custom_group manage_private_channel_members read_jobs sysconsole_write_experimental_features read_other_users_teams sysconsole_write_reporting_team_statistics sysconsole_read_environment_file_storage create_post_bleve_indexes_job sysconsole_read_site_file_sharing_and_downloads playbook_private_make_public playbook_public_view create_user_access_token create_public_channel read_channel sysconsole_read_user_management_channels sysconsole_read_user_management_permissions read_public_channel sysconsole_read_compliance_custom_terms_of_service sysconsole_write_site_emoji sysconsole_read_integrations_gif sysconsole_read_site_customization sysconsole_write_integrations_cors invite_user create_direct_channel sysconsole_write_user_management_teams run_create manage_custom_group_members read_ldap_sync_job sysconsole_read_site_notifications playbook_private_manage_properties sysconsole_read_integrations_bot_accounts convert_public_channel_to_private invalidate_email_invite reload_config get_saml_metadata_from_idp manage_secure_connections delete_private_channel sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_environment_developer recycle_database_connections remove_saml_private_cert manage_oauth sysconsole_write_environment_database sysconsole_write_site_notifications sysconsole_write_authentication_guest_access sysconsole_write_compliance_compliance_monitoring sysconsole_write_environment_image_proxy create_post_public manage_jobs remove_user_from_team delete_others_posts create_post_ephemeral playbook_private_view create_elasticsearch_post_aggregation_job remove_reaction add_reaction sysconsole_write_environment_high_availability sysconsole_write_authentication_openid sysconsole_write_user_management_permissions add_saml_idp_cert sysconsole_read_site_posts view_members sysconsole_write_environment_smtp sysconsole_read_authentication_saml create_post use_channel_mentions create_team playbook_private_manage_roles get_public_link sysconsole_write_billing manage_system_wide_oauth sysconsole_read_environment_database sysconsole_write_environment_session_lengths run_manage_properties sysconsole_write_authentication_saml sysconsole_read_environment_web_server sysconsole_read_environment_rate_limiting manage_public_channel_properties create_group_channel sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_high_availability manage_others_slash_commands sysconsole_read_compliance_compliance_export delete_custom_group sysconsole_read_user_management_system_roles purge_elasticsearch_indexes view_team sysconsole_read_environment_performance_monitoring manage_channel_roles playbook_public_make_private remove_saml_public_cert demote_to_guest sysconsole_write_environment_performance_monitoring read_audits sysconsole_write_site_announcement_banner upload_file revoke_user_access_token read_others_bots test_email read_elasticsearch_post_aggregation_job sysconsole_read_compliance_compliance_monitoring join_private_teams delete_post sysconsole_write_site_public_links manage_team edit_custom_group sysconsole_write_experimental_feature_flags sysconsole_write_user_management_system_roles remove_others_reactions manage_license_information sysconsole_read_authentication_signup read_compliance_export_job sysconsole_write_environment_developer remove_saml_idp_cert manage_incoming_webhooks sysconsole_read_site_emoji assign_bot sysconsole_write_integrations_gif sysconsole_read_user_management_users delete_public_channel manage_outgoing_webhooks sysconsole_write_site_posts remove_ldap_private_cert sysconsole_write_site_file_sharing_and_downloads sysconsole_read_integrations_integration_management sysconsole_read_environment_logging test_site_url sysconsole_read_environment_session_lengths read_elasticsearch_post_indexing_job sysconsole_read_billing sysconsole_read_site_notices sysconsole_read_reporting_server_logs sysconsole_write_integrations_bot_accounts sysconsole_write_site_notices create_private_channel read_private_channel_groups run_view read_bots manage_roles test_s3 sysconsole_write_environment_push_notification_server get_logs invite_guest remove_ldap_public_cert sysconsole_read_environment_smtp',1,1); +INSERT INTO `Roles` VALUES ('kb6r9i58x7dxdb3srfohd66sse','system_admin','authentication.roles.global_admin.name','authentication.roles.global_admin.description',0,1662271986948,0,' list_public_teams edit_brand manage_private_channel_properties sysconsole_read_user_management_teams playbook_public_create manage_others_bots invalidate_caches manage_shared_channels sysconsole_write_environment_logging manage_others_outgoing_webhooks sysconsole_read_reporting_team_statistics sysconsole_read_plugins list_team_channels use_group_mentions sysconsole_read_site_users_and_teams sysconsole_write_site_localization get_analytics sysconsole_read_experimental_bleve manage_team_roles sysconsole_read_site_localization edit_post sysconsole_write_user_management_channels test_elasticsearch list_private_teams add_ldap_public_cert join_public_teams manage_slash_commands manage_others_incoming_webhooks manage_public_channel_members sysconsole_read_environment_elasticsearch sysconsole_write_site_customization delete_others_emojis run_manage_members create_emojis sysconsole_write_authentication_email sysconsole_write_compliance_compliance_export add_saml_private_cert create_bot sysconsole_write_environment_rate_limiting add_saml_public_cert edit_other_users sysconsole_write_integrations_integration_management read_user_access_token create_elasticsearch_post_indexing_job sysconsole_write_user_management_users assign_system_admin_role sysconsole_write_user_management_groups sysconsole_read_authentication_guest_access sysconsole_write_about_edition_and_license sysconsole_read_authentication_ldap sysconsole_read_experimental_feature_flags sysconsole_read_integrations_cors sysconsole_read_user_management_groups join_public_channels sysconsole_read_experimental_features test_ldap sysconsole_write_environment_elasticsearch sysconsole_write_reporting_server_logs sysconsole_read_environment_image_proxy sysconsole_read_site_announcement_banner sysconsole_read_reporting_site_statistics sysconsole_write_authentication_mfa sysconsole_read_authentication_openid purge_bleve_indexes playbook_public_manage_members delete_emojis sysconsole_write_environment_file_storage sysconsole_write_reporting_site_statistics playbook_private_manage_members import_team sysconsole_write_environment_web_server sysconsole_write_authentication_password read_public_channel_groups create_compliance_export_job sysconsole_read_authentication_password list_users_without_team sysconsole_read_authentication_mfa add_ldap_private_cert create_data_retention_job read_license_information sysconsole_write_authentication_signup sysconsole_read_environment_push_notification_server edit_others_posts download_compliance_export_result create_ldap_sync_job sysconsole_write_authentication_ldap sysconsole_write_plugins read_data_retention_job sysconsole_write_compliance_data_retention_policy sysconsole_read_site_public_links manage_bots manage_system sysconsole_write_compliance_custom_terms_of_service playbook_public_manage_roles playbook_public_manage_properties playbook_private_create sysconsole_write_experimental_bleve sysconsole_read_authentication_email promote_guest get_saml_cert_status add_user_to_team sysconsole_write_site_users_and_teams create_custom_group manage_private_channel_members read_jobs sysconsole_write_experimental_features read_other_users_teams sysconsole_write_reporting_team_statistics sysconsole_read_environment_file_storage create_post_bleve_indexes_job sysconsole_read_site_file_sharing_and_downloads playbook_private_make_public playbook_public_view create_user_access_token create_public_channel read_channel sysconsole_read_user_management_channels sysconsole_read_user_management_permissions read_public_channel sysconsole_read_compliance_custom_terms_of_service sysconsole_write_site_emoji sysconsole_read_integrations_gif sysconsole_read_site_customization sysconsole_write_integrations_cors invite_user create_direct_channel sysconsole_write_user_management_teams run_create manage_custom_group_members read_ldap_sync_job sysconsole_read_site_notifications playbook_private_manage_properties sysconsole_read_integrations_bot_accounts convert_public_channel_to_private invalidate_email_invite reload_config get_saml_metadata_from_idp manage_secure_connections delete_private_channel sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_environment_developer recycle_database_connections remove_saml_private_cert manage_oauth sysconsole_write_environment_database sysconsole_write_site_notifications sysconsole_write_authentication_guest_access sysconsole_write_compliance_compliance_monitoring sysconsole_write_environment_image_proxy create_post_public manage_jobs remove_user_from_team delete_others_posts create_post_ephemeral playbook_private_view create_elasticsearch_post_aggregation_job remove_reaction add_reaction sysconsole_write_environment_high_availability sysconsole_write_authentication_openid sysconsole_write_user_management_permissions add_saml_idp_cert sysconsole_read_site_posts view_members sysconsole_write_environment_smtp sysconsole_read_authentication_saml create_post use_channel_mentions create_team playbook_private_manage_roles get_public_link sysconsole_write_billing manage_system_wide_oauth sysconsole_read_environment_database sysconsole_write_environment_session_lengths run_manage_properties sysconsole_write_authentication_saml sysconsole_read_environment_web_server sysconsole_read_environment_rate_limiting manage_public_channel_properties create_group_channel sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_high_availability manage_others_slash_commands sysconsole_read_compliance_compliance_export delete_custom_group sysconsole_read_user_management_system_roles purge_elasticsearch_indexes view_team sysconsole_read_environment_performance_monitoring manage_channel_roles playbook_public_make_private remove_saml_public_cert demote_to_guest sysconsole_write_environment_performance_monitoring read_audits sysconsole_write_site_announcement_banner upload_file revoke_user_access_token read_others_bots test_email read_elasticsearch_post_aggregation_job sysconsole_read_compliance_compliance_monitoring join_private_teams delete_post sysconsole_write_site_public_links manage_team edit_custom_group sysconsole_write_experimental_feature_flags sysconsole_write_user_management_system_roles remove_others_reactions manage_license_information sysconsole_read_authentication_signup read_compliance_export_job sysconsole_write_environment_developer remove_saml_idp_cert manage_incoming_webhooks sysconsole_read_site_emoji assign_bot sysconsole_write_integrations_gif sysconsole_read_user_management_users delete_public_channel manage_outgoing_webhooks sysconsole_write_site_posts remove_ldap_private_cert sysconsole_write_site_file_sharing_and_downloads sysconsole_read_integrations_integration_management sysconsole_read_environment_logging test_site_url sysconsole_read_environment_session_lengths read_elasticsearch_post_indexing_job sysconsole_read_billing sysconsole_read_site_notices sysconsole_read_reporting_server_logs sysconsole_write_integrations_bot_accounts sysconsole_write_site_notices create_private_channel read_private_channel_groups run_view read_bots manage_roles test_s3 sysconsole_write_environment_push_notification_server get_logs invite_guest remove_ldap_public_cert sysconsole_read_environment_smtp',1,1); INSERT INTO `Roles` VALUES ('km7kijhdtjbajquwu36uqneyoc','system_post_all','authentication.roles.system_post_all.name','authentication.roles.system_post_all.description',0,1662271986953,0,' create_post use_channel_mentions',0,1); INSERT INTO `Roles` VALUES ('no7s4436sjbzzqjpupg85mszty','custom_group_user','authentication.roles.custom_group_user.name','authentication.roles.custom_group_user.description',1662271985801,1662271986956,0,'',0,0); INSERT INTO `Roles` VALUES ('qo7e17c1m3rezyjqx5iq9dpmxe','system_manager','authentication.roles.system_manager.name','authentication.roles.system_manager.description',0,1662271986960,0,' sysconsole_write_environment_image_proxy sysconsole_read_environment_developer read_ldap_sync_job sysconsole_read_reporting_team_statistics recycle_database_connections get_logs read_private_channel_groups test_elasticsearch sysconsole_read_environment_logging purge_elasticsearch_indexes sysconsole_write_site_posts sysconsole_read_environment_database sysconsole_read_environment_performance_monitoring manage_team sysconsole_read_authentication_password sysconsole_write_site_users_and_teams sysconsole_read_user_management_channels sysconsole_write_environment_rate_limiting sysconsole_write_site_notifications read_license_information edit_brand sysconsole_read_plugins sysconsole_read_environment_high_availability sysconsole_read_environment_file_storage sysconsole_read_environment_elasticsearch sysconsole_write_environment_web_server sysconsole_write_environment_smtp sysconsole_write_environment_performance_monitoring sysconsole_write_environment_session_lengths sysconsole_write_user_management_groups convert_private_channel_to_public manage_private_channel_properties sysconsole_read_site_posts list_private_teams sysconsole_read_authentication_ldap sysconsole_read_authentication_guest_access sysconsole_read_site_emoji sysconsole_write_integrations_integration_management convert_public_channel_to_private manage_private_channel_members read_elasticsearch_post_aggregation_job manage_team_roles sysconsole_write_site_file_sharing_and_downloads read_channel read_public_channel sysconsole_read_authentication_openid add_user_to_team sysconsole_write_environment_developer sysconsole_write_site_localization sysconsole_read_about_edition_and_license test_s3 reload_config sysconsole_write_environment_elasticsearch test_site_url sysconsole_write_site_announcement_banner get_analytics sysconsole_read_environment_push_notification_server sysconsole_read_authentication_signup test_email sysconsole_write_integrations_bot_accounts sysconsole_write_integrations_cors view_team sysconsole_write_integrations_gif sysconsole_read_site_notices sysconsole_read_environment_image_proxy sysconsole_read_integrations_cors sysconsole_write_environment_push_notification_server join_public_teams test_ldap create_elasticsearch_post_aggregation_job sysconsole_read_environment_session_lengths sysconsole_write_environment_file_storage manage_public_channel_members sysconsole_write_site_customization sysconsole_read_site_announcement_banner sysconsole_read_environment_smtp sysconsole_write_user_management_teams delete_public_channel sysconsole_write_environment_logging read_public_channel_groups sysconsole_read_site_users_and_teams sysconsole_read_reporting_site_statistics sysconsole_read_site_localization sysconsole_read_site_customization sysconsole_read_environment_rate_limiting sysconsole_read_environment_web_server sysconsole_write_user_management_permissions sysconsole_read_site_file_sharing_and_downloads sysconsole_write_site_public_links sysconsole_read_site_public_links sysconsole_read_authentication_email read_elasticsearch_post_indexing_job sysconsole_read_authentication_saml remove_user_from_team delete_private_channel sysconsole_write_user_management_channels sysconsole_read_reporting_server_logs sysconsole_read_integrations_bot_accounts sysconsole_read_user_management_teams list_public_teams create_elasticsearch_post_indexing_job sysconsole_write_site_emoji invalidate_caches sysconsole_read_integrations_integration_management sysconsole_write_environment_high_availability sysconsole_read_user_management_permissions join_private_teams manage_channel_roles sysconsole_write_site_notices manage_public_channel_properties sysconsole_write_environment_database sysconsole_read_site_notifications sysconsole_read_user_management_groups sysconsole_read_integrations_gif sysconsole_read_authentication_mfa',0,1); INSERT INTO `Roles` VALUES ('rkr97ikkh7fixy86qsoo5rqm4c','system_user_access_token','authentication.roles.system_user_access_token.name','authentication.roles.system_user_access_token.description',0,1662271986965,0,' create_user_access_token read_user_access_token revoke_user_access_token',0,1); INSERT INTO `Roles` VALUES ('rxzdk5irm7rcffcfej9e33kqeo','team_user','authentication.roles.team_user.name','authentication.roles.team_user.description',0,1662271986968,0,' invite_user view_team read_public_channel playbook_public_create add_user_to_team playbook_private_create create_private_channel list_team_channels create_public_channel join_public_channels',1,1); -INSERT INTO `Roles` VALUES ('x768jnyzw3rkfx7xb66ehcac6o','channel_user','authentication.roles.channel_user.name','authentication.roles.channel_user.description',0,1662271986972,0,' manage_public_channel_properties create_post manage_private_channel_properties delete_public_channel manage_private_channel_members get_public_link delete_post delete_private_channel upload_file edit_post remove_reaction use_channel_mentions add_reaction read_channel use_slash_commands manage_public_channel_members',1,1); -INSERT INTO `Roles` VALUES ('ynn8aynsn7n1trtbuq6p4cyzhe','channel_guest','authentication.roles.channel_guest.name','authentication.roles.channel_guest.description',1605167829001,1662271986975,0,' read_channel add_reaction remove_reaction upload_file edit_post create_post use_channel_mentions use_slash_commands',1,1); +INSERT INTO `Roles` VALUES ('x768jnyzw3rkfx7xb66ehcac6o','channel_user','authentication.roles.channel_user.name','authentication.roles.channel_user.description',0,1662271986972,0,' manage_public_channel_properties create_post manage_private_channel_properties delete_public_channel manage_private_channel_members get_public_link delete_post delete_private_channel upload_file edit_post remove_reaction use_channel_mentions add_reaction read_channel manage_public_channel_members',1,1); +INSERT INTO `Roles` VALUES ('ynn8aynsn7n1trtbuq6p4cyzhe','channel_guest','authentication.roles.channel_guest.name','authentication.roles.channel_guest.description',1605167829001,1662271986975,0,' read_channel add_reaction remove_reaction upload_file edit_post create_post use_channel_mentions',1,1); INSERT INTO `Roles` VALUES ('yqyby79r9jggxg7a9dnenuawmo','run_member','authentication.roles.run_member.name','authentication.roles.run_member.description',1662271985813,1662271986979,0,' run_view',1,1); INSERT INTO `Roles` VALUES ('zzehkfnp67bg5g1owh6eptdcxc','system_user','authentication.roles.global_user.name','authentication.roles.global_user.description',0,1662271986983,0,' create_emojis join_public_teams list_public_teams edit_custom_group delete_emojis create_team create_group_channel manage_custom_group_members view_members delete_custom_group create_custom_group create_direct_channel',1,1); /*!40000 ALTER TABLE `Roles` ENABLE KEYS */; diff --git a/server/channels/testlib/testdata/postgres_migration_warmup.sql b/server/channels/testlib/testdata/postgres_migration_warmup.sql index 4dc1481c3a..b58b54e62e 100644 --- a/server/channels/testlib/testdata/postgres_migration_warmup.sql +++ b/server/channels/testlib/testdata/postgres_migration_warmup.sql @@ -17,7 +17,7 @@ SET client_encoding = 'UTF8'; INSERT INTO public.roles VALUES ('gkegg9mqi3rgbm9u444mnxkmbc', 'team_post_all_public', 'authentication.roles.team_post_all_public.name', 'authentication.roles.team_post_all_public.description', 0, 1662230812026, 0, ' create_post_public use_channel_mentions', false, true); INSERT INTO public.roles VALUES ('7ta1wfbacjy3zxid54n3cqjzqw', 'system_post_all_public', 'authentication.roles.system_post_all_public.name', 'authentication.roles.system_post_all_public.description', 0, 1662230812027, 0, ' create_post_public use_channel_mentions', false, true); INSERT INTO public.roles VALUES ('xf95ytghtjfsfd543dum68uzua', 'system_user_access_token', 'authentication.roles.system_user_access_token.name', 'authentication.roles.system_user_access_token.description', 0, 1662230812027, 0, ' create_user_access_token read_user_access_token revoke_user_access_token', false, true); -INSERT INTO public.roles VALUES ('nh5i9ik1u78hdcny9usdoixkuo', 'channel_user', 'authentication.roles.channel_user.name', 'authentication.roles.channel_user.description', 0, 1662230812029, 0, ' delete_post delete_public_channel use_channel_mentions manage_private_channel_properties manage_public_channel_properties delete_private_channel upload_file read_channel use_slash_commands get_public_link remove_reaction create_post add_reaction manage_private_channel_members edit_post manage_public_channel_members', true, true); +INSERT INTO public.roles VALUES ('nh5i9ik1u78hdcny9usdoixkuo', 'channel_user', 'authentication.roles.channel_user.name', 'authentication.roles.channel_user.description', 0, 1662230812029, 0, ' delete_post delete_public_channel use_channel_mentions manage_private_channel_properties manage_public_channel_properties delete_private_channel upload_file read_channel get_public_link remove_reaction create_post add_reaction manage_private_channel_members edit_post manage_public_channel_members', true, true); INSERT INTO public.roles VALUES ('peooyqpsq7g5bfnfo45zb1jiro', 'system_guest', 'authentication.roles.global_guest.name', 'authentication.roles.global_guest.description', 1605163387739, 1662230812021, 0, ' create_group_channel create_direct_channel', true, true); INSERT INTO public.roles VALUES ('96whs8mg73dszp7cz4u7sdbd7c', 'team_guest', 'authentication.roles.team_guest.name', 'authentication.roles.team_guest.description', 1605163387741, 1662230812022, 0, ' view_team', true, true); INSERT INTO public.roles VALUES ('rfc1w7z71pnzurkhpb1jgrbmdh', 'team_user', 'authentication.roles.team_user.name', 'authentication.roles.team_user.description', 1605163387747, 1662230812023, 0, ' playbook_public_create view_team invite_user playbook_private_create list_team_channels join_public_channels create_private_channel add_user_to_team read_public_channel create_public_channel', true, true); @@ -26,14 +26,14 @@ INSERT INTO public.roles VALUES ('wxat9mo53tg79xdzn55kdq148w', 'channel_admin', INSERT INTO public.roles VALUES ('13kpq8iaqffmdf9qkrfqmpby9h', 'team_admin', 'authentication.roles.team_admin.name', 'authentication.roles.team_admin.description', 0, 1662230812024, 0, ' manage_incoming_webhooks manage_others_incoming_webhooks import_team manage_others_outgoing_webhooks manage_team_roles remove_user_from_team manage_team manage_outgoing_webhooks manage_slash_commands convert_public_channel_to_private playbook_public_manage_roles manage_others_slash_commands delete_others_posts delete_post manage_channel_roles convert_private_channel_to_public playbook_private_manage_roles', true, true); INSERT INTO public.roles VALUES ('tj3atgnwjfrt7emz8pgqmh5z4c', 'team_post_all', 'authentication.roles.team_post_all.name', 'authentication.roles.team_post_all.description', 0, 1662230812030, 0, ' create_post use_channel_mentions', false, true); INSERT INTO public.roles VALUES ('d54xjt4sat8h7dqwu6i35jocuy', 'system_user', 'authentication.roles.global_user.name', 'authentication.roles.global_user.description', 0, 1662230812030, 0, ' create_emojis edit_custom_group manage_custom_group_members view_members create_custom_group create_team create_direct_channel delete_custom_group list_public_teams delete_emojis create_group_channel join_public_teams', true, true); -INSERT INTO public.roles VALUES ('mrejpofuoffiiynqcsi98es9ya', 'channel_guest', 'authentication.roles.channel_guest.name', 'authentication.roles.channel_guest.description', 0, 1662230812026, 0, ' upload_file edit_post create_post use_channel_mentions use_slash_commands read_channel add_reaction remove_reaction', true, true); +INSERT INTO public.roles VALUES ('mrejpofuoffiiynqcsi98es9ya', 'channel_guest', 'authentication.roles.channel_guest.name', 'authentication.roles.channel_guest.description', 0, 1662230812026, 0, ' upload_file edit_post create_post use_channel_mentions read_channel add_reaction remove_reaction', true, true); INSERT INTO public.roles VALUES ('4fk7nq4jgi8t7n1re79eb7i96c', 'custom_group_user', 'authentication.roles.custom_group_user.name', 'authentication.roles.custom_group_user.description', 1662230811506, 1662230812031, 0, '', false, false); INSERT INTO public.roles VALUES ('qmagi7t1ifbjuy5r1pp53eoryo', 'playbook_admin', 'authentication.roles.playbook_admin.name', 'authentication.roles.playbook_admin.description', 1662230811507, 1662230812032, 0, ' playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members', true, true); INSERT INTO public.roles VALUES ('ozgjpnirx7fdjp3i1i8jrg1kwc', 'system_custom_group_admin', 'authentication.roles.system_custom_group_admin.name', 'authentication.roles.system_custom_group_admin.description', 1662230811510, 1662230812032, 0, ' create_custom_group edit_custom_group delete_custom_group manage_custom_group_members', false, true); INSERT INTO public.roles VALUES ('pfnwpqmbmjrexgqbxdu61wfd3w', 'playbook_member', 'authentication.roles.playbook_member.name', 'authentication.roles.playbook_member.description', 1662230811533, 1662230812034, 0, ' playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create', true, true); INSERT INTO public.roles VALUES ('dj5zm9bxbidi9ritmana9t1sxh', 'run_admin', 'authentication.roles.run_admin.name', 'authentication.roles.run_admin.description', 1662230811534, 1662230812035, 0, ' run_manage_members run_manage_properties', true, true); INSERT INTO public.roles VALUES ('abrocgnx8pni7esbrmb4pjxhoe', 'run_member', 'authentication.roles.run_member.name', 'authentication.roles.run_member.description', 1662230811534, 1662230812036, 0, ' run_view', true, true); -INSERT INTO public.roles VALUES ('ha8u9qxwx3dm8mnbq8sfi7ugdc', 'system_admin', 'authentication.roles.global_admin.name', 'authentication.roles.global_admin.description', 0, 1662230812038, 0, ' read_public_channel_groups manage_public_channel_properties create_post_ephemeral sysconsole_write_site_localization sysconsole_write_billing sysconsole_read_site_file_sharing_and_downloads playbook_public_manage_roles sysconsole_read_integrations_gif delete_emojis sysconsole_write_experimental_features sysconsole_write_site_posts add_ldap_private_cert use_group_mentions sysconsole_read_authentication_openid add_user_to_team sysconsole_read_user_management_channels sysconsole_write_environment_high_availability sysconsole_write_site_announcement_banner sysconsole_read_site_notices sysconsole_write_user_management_teams convert_public_channel_to_private sysconsole_read_reporting_server_logs manage_system_wide_oauth revoke_user_access_token invalidate_caches sysconsole_write_environment_push_notification_server sysconsole_read_site_emoji remove_others_reactions sysconsole_write_reporting_server_logs sysconsole_write_user_management_permissions sysconsole_read_site_posts assign_bot sysconsole_write_authentication_password add_saml_private_cert manage_jobs sysconsole_write_environment_developer use_channel_mentions add_ldap_public_cert purge_bleve_indexes playbook_public_manage_properties sysconsole_read_authentication_mfa read_public_channel sysconsole_read_environment_image_proxy import_team sysconsole_read_reporting_team_statistics sysconsole_write_user_management_channels list_private_teams sysconsole_read_user_management_groups join_private_teams sysconsole_read_compliance_data_retention_policy list_public_teams sysconsole_read_site_localization sysconsole_write_authentication_guest_access sysconsole_read_compliance_compliance_monitoring sysconsole_read_environment_developer edit_others_posts sysconsole_read_experimental_bleve read_audits sysconsole_write_authentication_email sysconsole_write_experimental_bleve sysconsole_read_environment_push_notification_server read_elasticsearch_post_aggregation_job remove_ldap_private_cert manage_team manage_bots sysconsole_write_environment_session_lengths sysconsole_write_user_management_users sysconsole_write_environment_file_storage invite_user join_public_channels create_direct_channel sysconsole_read_site_users_and_teams manage_slash_commands playbook_public_view sysconsole_write_compliance_custom_terms_of_service purge_elasticsearch_indexes sysconsole_read_authentication_email test_ldap sysconsole_write_plugins manage_outgoing_webhooks create_bot create_compliance_export_job get_logs create_private_channel get_saml_metadata_from_idp read_elasticsearch_post_indexing_job get_analytics manage_incoming_webhooks sysconsole_read_authentication_saml invite_guest manage_shared_channels create_public_channel sysconsole_write_site_file_sharing_and_downloads sysconsole_read_environment_rate_limiting manage_public_channel_members sysconsole_read_environment_file_storage sysconsole_read_environment_performance_monitoring sysconsole_write_environment_performance_monitoring sysconsole_write_integrations_gif create_post_public playbook_public_manage_members upload_file sysconsole_write_reporting_team_statistics manage_team_roles sysconsole_read_site_notifications delete_public_channel sysconsole_write_compliance_compliance_monitoring create_ldap_sync_job create_data_retention_job sysconsole_write_environment_smtp manage_custom_group_members manage_others_slash_commands read_ldap_sync_job sysconsole_read_integrations_bot_accounts read_others_bots read_bots sysconsole_read_authentication_ldap demote_to_guest remove_saml_public_cert create_post_bleve_indexes_job sysconsole_read_user_management_teams sysconsole_write_about_edition_and_license remove_ldap_public_cert read_channel sysconsole_read_environment_database sysconsole_write_authentication_signup test_s3 sysconsole_read_environment_high_availability manage_roles sysconsole_write_site_notifications run_view sysconsole_write_authentication_saml invalidate_email_invite playbook_private_view read_compliance_export_job list_users_without_team sysconsole_read_compliance_compliance_export sysconsole_write_integrations_cors promote_guest manage_oauth read_data_retention_job sysconsole_write_experimental_feature_flags sysconsole_read_environment_session_lengths manage_license_information sysconsole_write_authentication_ldap assign_system_admin_role create_post read_private_channel_groups add_saml_idp_cert playbook_private_create manage_private_channel_properties sysconsole_read_compliance_custom_terms_of_service sysconsole_read_integrations_integration_management sysconsole_read_billing sysconsole_read_authentication_password delete_private_channel sysconsole_write_site_notices create_elasticsearch_post_indexing_job test_email sysconsole_write_environment_database recycle_database_connections edit_brand sysconsole_write_authentication_mfa remove_user_from_team sysconsole_write_user_management_system_roles add_reaction remove_saml_private_cert sysconsole_read_environment_web_server run_create sysconsole_read_authentication_guest_access sysconsole_read_about_edition_and_license run_manage_properties create_user_access_token manage_others_incoming_webhooks create_elasticsearch_post_aggregation_job sysconsole_write_user_management_groups sysconsole_read_experimental_feature_flags create_team sysconsole_read_environment_elasticsearch join_public_teams sysconsole_read_user_management_users sysconsole_read_integrations_cors sysconsole_read_environment_smtp manage_secure_connections manage_channel_roles edit_other_users delete_others_emojis sysconsole_write_site_users_and_teams add_saml_public_cert sysconsole_read_site_announcement_banner create_custom_group download_compliance_export_result create_group_channel get_saml_cert_status sysconsole_read_site_public_links manage_system create_emojis sysconsole_read_authentication_signup sysconsole_write_environment_image_proxy list_team_channels remove_saml_idp_cert sysconsole_read_plugins sysconsole_read_site_customization sysconsole_write_site_customization use_slash_commands playbook_private_manage_roles delete_custom_group delete_others_posts sysconsole_write_compliance_data_retention_policy sysconsole_write_environment_logging test_elasticsearch playbook_public_make_private sysconsole_write_site_public_links edit_post playbook_private_make_public sysconsole_write_environment_elasticsearch test_site_url sysconsole_write_compliance_compliance_export playbook_private_manage_members delete_post reload_config edit_custom_group sysconsole_read_user_management_system_roles sysconsole_write_reporting_site_statistics sysconsole_write_site_emoji read_user_access_token sysconsole_write_environment_rate_limiting view_members sysconsole_write_integrations_bot_accounts manage_others_bots manage_others_outgoing_webhooks sysconsole_read_environment_logging sysconsole_read_experimental_features sysconsole_write_authentication_openid manage_private_channel_members read_jobs sysconsole_write_environment_web_server read_license_information sysconsole_read_user_management_permissions view_team convert_private_channel_to_public sysconsole_read_reporting_site_statistics get_public_link read_other_users_teams sysconsole_write_integrations_integration_management run_manage_members playbook_public_create remove_reaction playbook_private_manage_properties', true, true); +INSERT INTO public.roles VALUES ('ha8u9qxwx3dm8mnbq8sfi7ugdc', 'system_admin', 'authentication.roles.global_admin.name', 'authentication.roles.global_admin.description', 0, 1662230812038, 0, ' read_public_channel_groups manage_public_channel_properties create_post_ephemeral sysconsole_write_site_localization sysconsole_write_billing sysconsole_read_site_file_sharing_and_downloads playbook_public_manage_roles sysconsole_read_integrations_gif delete_emojis sysconsole_write_experimental_features sysconsole_write_site_posts add_ldap_private_cert use_group_mentions sysconsole_read_authentication_openid add_user_to_team sysconsole_read_user_management_channels sysconsole_write_environment_high_availability sysconsole_write_site_announcement_banner sysconsole_read_site_notices sysconsole_write_user_management_teams convert_public_channel_to_private sysconsole_read_reporting_server_logs manage_system_wide_oauth revoke_user_access_token invalidate_caches sysconsole_write_environment_push_notification_server sysconsole_read_site_emoji remove_others_reactions sysconsole_write_reporting_server_logs sysconsole_write_user_management_permissions sysconsole_read_site_posts assign_bot sysconsole_write_authentication_password add_saml_private_cert manage_jobs sysconsole_write_environment_developer use_channel_mentions add_ldap_public_cert purge_bleve_indexes playbook_public_manage_properties sysconsole_read_authentication_mfa read_public_channel sysconsole_read_environment_image_proxy import_team sysconsole_read_reporting_team_statistics sysconsole_write_user_management_channels list_private_teams sysconsole_read_user_management_groups join_private_teams sysconsole_read_compliance_data_retention_policy list_public_teams sysconsole_read_site_localization sysconsole_write_authentication_guest_access sysconsole_read_compliance_compliance_monitoring sysconsole_read_environment_developer edit_others_posts sysconsole_read_experimental_bleve read_audits sysconsole_write_authentication_email sysconsole_write_experimental_bleve sysconsole_read_environment_push_notification_server read_elasticsearch_post_aggregation_job remove_ldap_private_cert manage_team manage_bots sysconsole_write_environment_session_lengths sysconsole_write_user_management_users sysconsole_write_environment_file_storage invite_user join_public_channels create_direct_channel sysconsole_read_site_users_and_teams manage_slash_commands playbook_public_view sysconsole_write_compliance_custom_terms_of_service purge_elasticsearch_indexes sysconsole_read_authentication_email test_ldap sysconsole_write_plugins manage_outgoing_webhooks create_bot create_compliance_export_job get_logs create_private_channel get_saml_metadata_from_idp read_elasticsearch_post_indexing_job get_analytics manage_incoming_webhooks sysconsole_read_authentication_saml invite_guest manage_shared_channels create_public_channel sysconsole_write_site_file_sharing_and_downloads sysconsole_read_environment_rate_limiting manage_public_channel_members sysconsole_read_environment_file_storage sysconsole_read_environment_performance_monitoring sysconsole_write_environment_performance_monitoring sysconsole_write_integrations_gif create_post_public playbook_public_manage_members upload_file sysconsole_write_reporting_team_statistics manage_team_roles sysconsole_read_site_notifications delete_public_channel sysconsole_write_compliance_compliance_monitoring create_ldap_sync_job create_data_retention_job sysconsole_write_environment_smtp manage_custom_group_members manage_others_slash_commands read_ldap_sync_job sysconsole_read_integrations_bot_accounts read_others_bots read_bots sysconsole_read_authentication_ldap demote_to_guest remove_saml_public_cert create_post_bleve_indexes_job sysconsole_read_user_management_teams sysconsole_write_about_edition_and_license remove_ldap_public_cert read_channel sysconsole_read_environment_database sysconsole_write_authentication_signup test_s3 sysconsole_read_environment_high_availability manage_roles sysconsole_write_site_notifications run_view sysconsole_write_authentication_saml invalidate_email_invite playbook_private_view read_compliance_export_job list_users_without_team sysconsole_read_compliance_compliance_export sysconsole_write_integrations_cors promote_guest manage_oauth read_data_retention_job sysconsole_write_experimental_feature_flags sysconsole_read_environment_session_lengths manage_license_information sysconsole_write_authentication_ldap assign_system_admin_role create_post read_private_channel_groups add_saml_idp_cert playbook_private_create manage_private_channel_properties sysconsole_read_compliance_custom_terms_of_service sysconsole_read_integrations_integration_management sysconsole_read_billing sysconsole_read_authentication_password delete_private_channel sysconsole_write_site_notices create_elasticsearch_post_indexing_job test_email sysconsole_write_environment_database recycle_database_connections edit_brand sysconsole_write_authentication_mfa remove_user_from_team sysconsole_write_user_management_system_roles add_reaction remove_saml_private_cert sysconsole_read_environment_web_server run_create sysconsole_read_authentication_guest_access sysconsole_read_about_edition_and_license run_manage_properties create_user_access_token manage_others_incoming_webhooks create_elasticsearch_post_aggregation_job sysconsole_write_user_management_groups sysconsole_read_experimental_feature_flags create_team sysconsole_read_environment_elasticsearch join_public_teams sysconsole_read_user_management_users sysconsole_read_integrations_cors sysconsole_read_environment_smtp manage_secure_connections manage_channel_roles edit_other_users delete_others_emojis sysconsole_write_site_users_and_teams add_saml_public_cert sysconsole_read_site_announcement_banner create_custom_group download_compliance_export_result create_group_channel get_saml_cert_status sysconsole_read_site_public_links manage_system create_emojis sysconsole_read_authentication_signup sysconsole_write_environment_image_proxy list_team_channels remove_saml_idp_cert sysconsole_read_plugins sysconsole_read_site_customization sysconsole_write_site_customization playbook_private_manage_roles delete_custom_group delete_others_posts sysconsole_write_compliance_data_retention_policy sysconsole_write_environment_logging test_elasticsearch playbook_public_make_private sysconsole_write_site_public_links edit_post playbook_private_make_public sysconsole_write_environment_elasticsearch test_site_url sysconsole_write_compliance_compliance_export playbook_private_manage_members delete_post reload_config edit_custom_group sysconsole_read_user_management_system_roles sysconsole_write_reporting_site_statistics sysconsole_write_site_emoji read_user_access_token sysconsole_write_environment_rate_limiting view_members sysconsole_write_integrations_bot_accounts manage_others_bots manage_others_outgoing_webhooks sysconsole_read_environment_logging sysconsole_read_experimental_features sysconsole_write_authentication_openid manage_private_channel_members read_jobs sysconsole_write_environment_web_server read_license_information sysconsole_read_user_management_permissions view_team convert_private_channel_to_public sysconsole_read_reporting_site_statistics get_public_link read_other_users_teams sysconsole_write_integrations_integration_management run_manage_members playbook_public_create remove_reaction playbook_private_manage_properties', true, true); INSERT INTO public.roles VALUES ('hm1bxei8b3d68e4j95tqnndppw', 'system_manager', 'authentication.roles.system_manager.name', 'authentication.roles.system_manager.description', 0, 1662230812025, 0, ' manage_private_channel_members join_public_teams sysconsole_write_site_announcement_banner sysconsole_write_site_emoji manage_public_channel_members purge_elasticsearch_indexes sysconsole_read_authentication_openid sysconsole_read_about_edition_and_license edit_brand sysconsole_read_reporting_team_statistics sysconsole_read_site_file_sharing_and_downloads sysconsole_read_user_management_teams read_private_channel_groups delete_public_channel sysconsole_read_site_customization sysconsole_write_site_notices sysconsole_read_authentication_email sysconsole_write_environment_file_storage sysconsole_read_user_management_permissions sysconsole_read_reporting_site_statistics test_s3 sysconsole_write_user_management_permissions sysconsole_read_environment_rate_limiting read_license_information sysconsole_read_environment_file_storage sysconsole_write_environment_elasticsearch invalidate_caches sysconsole_read_integrations_cors sysconsole_write_user_management_teams add_user_to_team sysconsole_read_environment_performance_monitoring get_logs sysconsole_write_environment_high_availability sysconsole_read_authentication_signup manage_public_channel_properties sysconsole_write_integrations_integration_management read_elasticsearch_post_indexing_job sysconsole_read_user_management_groups view_team sysconsole_write_environment_rate_limiting sysconsole_read_authentication_guest_access sysconsole_read_environment_elasticsearch manage_team reload_config manage_team_roles test_ldap sysconsole_read_site_public_links sysconsole_read_authentication_saml sysconsole_write_integrations_cors read_public_channel_groups sysconsole_write_site_users_and_teams sysconsole_read_integrations_gif get_analytics create_elasticsearch_post_indexing_job sysconsole_read_authentication_ldap sysconsole_read_site_announcement_banner test_site_url sysconsole_read_site_localization sysconsole_write_environment_push_notification_server sysconsole_write_integrations_bot_accounts sysconsole_write_environment_performance_monitoring sysconsole_write_site_posts sysconsole_read_environment_logging read_elasticsearch_post_aggregation_job sysconsole_write_site_localization sysconsole_write_environment_database sysconsole_read_site_posts sysconsole_write_environment_developer sysconsole_read_site_emoji sysconsole_read_plugins create_elasticsearch_post_aggregation_job manage_channel_roles sysconsole_write_user_management_groups remove_user_from_team read_ldap_sync_job sysconsole_write_site_notifications recycle_database_connections test_email sysconsole_read_site_notifications list_public_teams sysconsole_write_site_customization sysconsole_read_environment_smtp sysconsole_read_authentication_mfa sysconsole_read_integrations_integration_management sysconsole_read_user_management_channels sysconsole_read_reporting_server_logs sysconsole_write_site_public_links test_elasticsearch sysconsole_write_environment_smtp sysconsole_read_environment_push_notification_server sysconsole_write_environment_web_server sysconsole_write_environment_logging sysconsole_read_environment_session_lengths sysconsole_read_site_notices sysconsole_read_environment_high_availability join_private_teams sysconsole_read_authentication_password sysconsole_read_environment_developer delete_private_channel sysconsole_read_integrations_bot_accounts sysconsole_write_environment_session_lengths convert_private_channel_to_public sysconsole_read_environment_database sysconsole_read_environment_image_proxy convert_public_channel_to_private manage_private_channel_properties sysconsole_write_site_file_sharing_and_downloads read_public_channel list_private_teams sysconsole_write_integrations_gif sysconsole_read_environment_web_server sysconsole_read_site_users_and_teams sysconsole_write_user_management_channels read_channel sysconsole_write_environment_image_proxy', false, true); INSERT INTO public.roles VALUES ('f9drbz6cyjdmb8jof6smiqya7h', 'system_user_manager', 'authentication.roles.system_user_manager.name', 'authentication.roles.system_user_manager.description', 0, 1662230812028, 0, ' manage_team_roles sysconsole_read_authentication_saml manage_public_channel_members manage_channel_roles add_user_to_team sysconsole_read_authentication_ldap read_public_channel_groups join_public_teams convert_private_channel_to_public join_private_teams sysconsole_read_user_management_teams list_public_teams sysconsole_read_authentication_email list_private_teams sysconsole_read_authentication_signup read_public_channel sysconsole_read_authentication_mfa sysconsole_read_authentication_guest_access test_ldap manage_private_channel_members sysconsole_read_user_management_permissions read_channel remove_user_from_team delete_public_channel sysconsole_write_user_management_channels delete_private_channel sysconsole_read_authentication_openid sysconsole_write_user_management_teams manage_team sysconsole_read_user_management_groups view_team sysconsole_write_user_management_groups sysconsole_read_user_management_channels manage_public_channel_properties manage_private_channel_properties sysconsole_read_authentication_password read_ldap_sync_job convert_public_channel_to_private read_private_channel_groups', false, true); INSERT INTO public.roles VALUES ('tkioqq1sgtribqgjbzwop1846c', 'system_read_only_admin', 'authentication.roles.system_read_only_admin.name', 'authentication.roles.system_read_only_admin.description', 0, 1662230812033, 0, ' sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_openid sysconsole_read_user_management_users sysconsole_read_authentication_saml read_ldap_sync_job read_other_users_teams sysconsole_read_user_management_permissions download_compliance_export_result sysconsole_read_environment_smtp sysconsole_read_site_localization read_public_channel read_audits sysconsole_read_compliance_custom_terms_of_service read_data_retention_job sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_developer sysconsole_read_site_file_sharing_and_downloads sysconsole_read_user_management_channels read_elasticsearch_post_indexing_job sysconsole_read_authentication_mfa sysconsole_read_compliance_compliance_monitoring sysconsole_read_authentication_signup sysconsole_read_authentication_ldap sysconsole_read_authentication_password get_analytics sysconsole_read_site_posts sysconsole_read_environment_performance_monitoring sysconsole_read_compliance_compliance_export sysconsole_read_integrations_integration_management test_ldap sysconsole_read_environment_file_storage sysconsole_read_environment_logging sysconsole_read_user_management_groups sysconsole_read_environment_high_availability sysconsole_read_environment_database sysconsole_read_environment_elasticsearch sysconsole_read_environment_push_notification_server sysconsole_read_site_notices read_compliance_export_job read_license_information sysconsole_read_environment_session_lengths read_private_channel_groups sysconsole_read_integrations_gif read_elasticsearch_post_aggregation_job sysconsole_read_experimental_bleve sysconsole_read_reporting_team_statistics sysconsole_read_about_edition_and_license sysconsole_read_environment_image_proxy sysconsole_read_site_customization sysconsole_read_environment_rate_limiting view_team sysconsole_read_site_announcement_banner sysconsole_read_environment_web_server get_logs sysconsole_read_experimental_feature_flags sysconsole_read_integrations_cors sysconsole_read_authentication_guest_access sysconsole_read_plugins read_channel list_public_teams sysconsole_read_user_management_teams sysconsole_read_reporting_server_logs sysconsole_read_experimental_features sysconsole_read_authentication_email sysconsole_read_site_notifications sysconsole_read_site_users_and_teams sysconsole_read_reporting_site_statistics read_public_channel_groups list_private_teams sysconsole_read_site_public_links', false, true); diff --git a/server/model/permission.go b/server/model/permission.go index 231154e2d4..e91809127e 100644 --- a/server/model/permission.go +++ b/server/model/permission.go @@ -21,10 +21,6 @@ type Permission struct { var PermissionInviteUser *Permission var PermissionAddUserToTeam *Permission - -// Deprecated: PermissionCreatePost should be used to determine if a slash command can be executed. -// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 -var PermissionUseSlashCommands *Permission var PermissionManageSlashCommands *Permission var PermissionManageOthersSlashCommands *Permission var PermissionCreatePublicChannel *Permission @@ -393,12 +389,6 @@ func initializePermissions() { "authentication.permissions.add_user_to_team.description", PermissionScopeTeam, } - PermissionUseSlashCommands = &Permission{ - "use_slash_commands", - "authentication.permissions.team_use_slash_commands.name", - "authentication.permissions.team_use_slash_commands.description", - PermissionScopeChannel, - } PermissionManageSlashCommands = &Permission{ "manage_slash_commands", "authentication.permissions.manage_slash_commands.name", @@ -2318,7 +2308,6 @@ func initializePermissions() { } ChannelScopedPermissions := []*Permission{ - PermissionUseSlashCommands, PermissionManagePublicChannelMembers, PermissionManagePrivateChannelMembers, PermissionManageChannelRoles, diff --git a/server/model/role.go b/server/model/role.go index 2c7a8fbf7b..4fba0c64f7 100644 --- a/server/model/role.go +++ b/server/model/role.go @@ -755,7 +755,6 @@ func MakeDefaultRoles() map[string]*Role { PermissionEditPost.Id, PermissionCreatePost.Id, PermissionUseChannelMentions.Id, - PermissionUseSlashCommands.Id, }, SchemeManaged: true, BuiltIn: true, @@ -774,7 +773,6 @@ func MakeDefaultRoles() map[string]*Role { PermissionGetPublicLink.Id, PermissionCreatePost.Id, PermissionUseChannelMentions.Id, - PermissionUseSlashCommands.Id, PermissionManagePublicChannelProperties.Id, PermissionDeletePublicChannel.Id, PermissionManagePrivateChannelProperties.Id, diff --git a/server/model/role_test.go b/server/model/role_test.go index 431a3286f1..d6142841dc 100644 --- a/server/model/role_test.go +++ b/server/model/role_test.go @@ -71,7 +71,6 @@ func TestRolePatchFromChannelModerationsPatch(t *testing.T) { PermissionManagePublicChannelMembers.Id, PermissionUploadFile.Id, PermissionGetPublicLink.Id, - PermissionUseSlashCommands.Id, } baseModeratedPermissions := []string{ diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts index 069f4e7e53..19ff5a3ccb 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts @@ -4,7 +4,6 @@ const values = { INVITE_USER: 'invite_user', ADD_USER_TO_TEAM: 'add_user_to_team', - USE_SLASH_COMMANDS: 'use_slash_commands', MANAGE_SLASH_COMMANDS: 'manage_slash_commands', MANAGE_OTHERS_SLASH_COMMANDS: 'manage_others_slash_commands', CREATE_PUBLIC_CHANNEL: 'create_public_channel', diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 97ea8d65d0..29f0ef75f5 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1136,7 +1136,6 @@ export const PermissionsScope = { [Permissions.INVITE_USER]: 'team_scope', [Permissions.INVITE_GUEST]: 'team_scope', [Permissions.ADD_USER_TO_TEAM]: 'team_scope', - [Permissions.USE_SLASH_COMMANDS]: 'channel_scope', [Permissions.MANAGE_SLASH_COMMANDS]: 'team_scope', [Permissions.MANAGE_OTHERS_SLASH_COMMANDS]: 'team_scope', [Permissions.CREATE_PUBLIC_CHANNEL]: 'team_scope', @@ -1250,7 +1249,6 @@ export const DefaultRolePermissions = { Permissions.UPLOAD_FILE, Permissions.GET_PUBLIC_LINK, Permissions.CREATE_POST, - Permissions.USE_SLASH_COMMANDS, Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS, Permissions.DELETE_POST, Permissions.EDIT_POST, @@ -1315,7 +1313,6 @@ export const DefaultRolePermissions = { Permissions.ADD_REACTION, Permissions.REMOVE_REACTION, Permissions.USE_CHANNEL_MENTIONS, - Permissions.USE_SLASH_COMMANDS, Permissions.READ_CHANNEL, Permissions.UPLOAD_FILE, Permissions.CREATE_POST, From b7a5f22bcfcbdf5eac0e6b9331f250e36d050d9e Mon Sep 17 00:00:00 2001 From: Saturnino Abril Date: Tue, 25 Apr 2023 15:34:41 +0800 Subject: [PATCH 112/113] fix/e2e: remove use_slash_commands in roles (#23098) --- e2e-tests/cypress/tests/support/api/role.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e-tests/cypress/tests/support/api/role.js b/e2e-tests/cypress/tests/support/api/role.js index 6a8eed7082..d53862b72d 100644 --- a/e2e-tests/cypress/tests/support/api/role.js +++ b/e2e-tests/cypress/tests/support/api/role.js @@ -10,14 +10,14 @@ import xor from 'lodash.xor'; export const defaultRolesPermissions = { channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups', - channel_guest: 'upload_file edit_post create_post use_channel_mentions use_slash_commands read_channel add_reaction remove_reaction', - channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel use_slash_commands add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel delete_private_channel manage_public_channel_members create_post remove_reaction', + channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel add_reaction remove_reaction', + channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel delete_private_channel manage_public_channel_members create_post remove_reaction', custom_group_user: '', playbook_admin: 'playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles', playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create', run_admin: 'run_manage_properties run_manage_members', run_member: 'run_view', - system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization use_slash_commands manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private', + system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private', system_custom_group_admin: 'create_custom_group edit_custom_group delete_custom_group restore_custom_group manage_custom_group_members', system_guest: 'create_group_channel create_direct_channel', system_manager: ' sysconsole_read_site_announcement_banner manage_private_channel_properties edit_brand read_private_channel_groups manage_private_channel_members manage_team_roles sysconsole_write_environment_session_lengths sysconsole_read_site_emoji sysconsole_write_environment_developer sysconsole_read_user_management_groups sysconsole_write_user_management_groups sysconsole_write_environment_rate_limiting delete_private_channel sysconsole_read_environment_performance_monitoring sysconsole_read_environment_rate_limiting sysconsole_write_user_management_teams sysconsole_write_integrations_integration_management sysconsole_write_site_public_links sysconsole_read_authentication_ldap sysconsole_write_integrations_cors reload_config sysconsole_write_user_management_channels sysconsole_read_environment_high_availability sysconsole_read_site_users_and_teams sysconsole_read_user_management_teams sysconsole_write_site_users_and_teams sysconsole_read_site_customization sysconsole_write_environment_high_availability sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_guest_access sysconsole_read_site_public_links read_elasticsearch_post_indexing_job sysconsole_read_user_management_channels sysconsole_read_reporting_team_statistics invalidate_caches sysconsole_read_authentication_signup read_elasticsearch_post_aggregation_job sysconsole_write_environment_smtp manage_public_channel_members list_public_teams add_user_to_team sysconsole_read_environment_web_server sysconsole_read_site_localization get_logs sysconsole_write_site_posts sysconsole_write_integrations_bot_accounts sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_read_environment_smtp list_private_teams read_public_channel_groups sysconsole_write_environment_file_storage sysconsole_write_integrations_gif manage_public_channel_properties sysconsole_write_environment_performance_monitoring sysconsole_write_site_notifications sysconsole_read_site_notifications sysconsole_read_environment_image_proxy sysconsole_write_site_announcement_banner sysconsole_write_site_emoji test_site_url sysconsole_read_integrations_gif sysconsole_write_environment_logging convert_public_channel_to_private get_analytics sysconsole_read_user_management_permissions sysconsole_write_environment_image_proxy test_elasticsearch recycle_database_connections sysconsole_write_site_localization sysconsole_read_reporting_server_logs create_elasticsearch_post_indexing_job sysconsole_read_reporting_site_statistics test_ldap delete_public_channel sysconsole_write_environment_push_notification_server read_license_information sysconsole_write_products_boards sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_integrations_integration_management create_elasticsearch_post_aggregation_job purge_elasticsearch_indexes sysconsole_read_environment_database join_public_teams sysconsole_read_authentication_email sysconsole_read_environment_push_notification_server view_team read_channel sysconsole_read_authentication_password read_ldap_sync_job sysconsole_read_integrations_cors sysconsole_read_environment_logging manage_team sysconsole_read_authentication_openid read_public_channel sysconsole_write_environment_elasticsearch sysconsole_read_plugins manage_channel_roles remove_user_from_team test_email sysconsole_write_site_file_sharing_and_downloads test_s3 sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_notices sysconsole_read_environment_file_storage join_private_teams sysconsole_read_products_boards sysconsole_read_environment_session_lengths sysconsole_write_environment_database sysconsole_read_authentication_saml sysconsole_read_authentication_mfa sysconsole_write_site_notices sysconsole_write_environment_web_server sysconsole_read_site_posts sysconsole_read_environment_developer sysconsole_write_site_customization', From 502708499d3da6593ca52f06d0a3601ebc06d9b8 Mon Sep 17 00:00:00 2001 From: Pantelis Vratsalis Date: Wed, 19 Apr 2023 13:56:49 +0300 Subject: [PATCH 113/113] [MM-51777] Wrap integrations backstage UI options --- webapp/channels/src/sass/utils/_flex.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webapp/channels/src/sass/utils/_flex.scss b/webapp/channels/src/sass/utils/_flex.scss index 07f58478bd..c882a68a39 100644 --- a/webapp/channels/src/sass/utils/_flex.scss +++ b/webapp/channels/src/sass/utils/_flex.scss @@ -17,3 +17,7 @@ -ms-flex-positive: 1; -ms-flex-preferred-size: 0; } + +.flex-wrap { + flex-wrap: wrap; +}