diff --git a/webapp/channels/src/components/admin_console/license_settings/index.ts b/webapp/channels/src/components/admin_console/license_settings/index.ts index 41c9a80a2f..e47e8dfa02 100644 --- a/webapp/channels/src/components/admin_console/license_settings/index.ts +++ b/webapp/channels/src/components/admin_console/license_settings/index.ts @@ -7,6 +7,7 @@ import type {Dispatch} from 'redux'; import {uploadLicense, removeLicense, getPrevTrialLicense} from 'mattermost-redux/actions/admin'; import {getLicenseConfig} from 'mattermost-redux/actions/general'; +import {getUsersLimits} from 'mattermost-redux/actions/limits'; import {getFilteredUsersStats} from 'mattermost-redux/actions/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getFilteredUsersStats as selectFilteredUserStats} from 'mattermost-redux/selectors/entities/users'; @@ -42,6 +43,7 @@ function mapDispatchToProps(dispatch: Dispatch) { requestTrialLicense, openModal, getFilteredUsersStats, + getUsersLimits, }, dispatch), }; } diff --git a/webapp/channels/src/components/admin_console/license_settings/license_settings.test.tsx b/webapp/channels/src/components/admin_console/license_settings/license_settings.test.tsx index 123e349d1e..3345102ea3 100644 --- a/webapp/channels/src/components/admin_console/license_settings/license_settings.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/license_settings.test.tsx @@ -54,6 +54,7 @@ describe('components/admin_console/license_settings/LicenseSettings', () => { upgradeToE0Status: jest.fn().mockImplementation(() => Promise.resolve({percentage: 0, error: null})), openModal: jest.fn(), getFilteredUsersStats: jest.fn(), + getUsersLimits: jest.fn(), }, }; diff --git a/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx b/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx index ecab1e29fd..03cd129b5a 100644 --- a/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx @@ -7,6 +7,7 @@ import {FormattedMessage} from 'react-intl'; import type {StatusOK} from '@mattermost/types/client4'; import type {ClientLicense} from '@mattermost/types/config'; import type {ServerError} from '@mattermost/types/errors'; +import type {UsersLimits} from '@mattermost/types/limits'; import type {GetFilteredUsersStatsOpts, UsersStats} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -46,7 +47,7 @@ type Props = { actions: { getLicenseConfig: () => void; uploadLicense: (file: File) => Promise; - removeLicense: () => Promise; + removeLicense: () => Promise>; getPrevTrialLicense: () => void; upgradeToE0: () => Promise; upgradeToE0Status: () => Promise<{percentage: number; error: string | JSX.Element | null}>; @@ -54,6 +55,7 @@ type Props = { ping: () => Promise<{status: string}>; requestTrialLicense: (users: number, termsAccepted: boolean, receiveEmailsAccepted: boolean, featureName: string) => Promise; openModal:

(modalData: ModalData

) => void; + getUsersLimits: () => Promise>; getFilteredUsersStats: (filters: GetFilteredUsersStatsOpts) => Promise<{ data?: UsersStats; error?: ServerError; @@ -179,8 +181,13 @@ export default class LicenseSettings extends React.PureComponent { return; } - this.props.actions.getPrevTrialLicense(); - await this.props.actions.getLicenseConfig(); + await Promise.all([ + this.props.actions.getPrevTrialLicense(), + this.props.actions.getLicenseConfig(), + ]); + + await this.props.actions.getUsersLimits(); + this.setState({serverError: null, removing: false}); }; diff --git a/webapp/channels/src/components/air_gapped_contact_sales_modal/index.tsx b/webapp/channels/src/components/air_gapped_contact_sales_modal/index.tsx index 69a33aa08b..7594a189b9 100644 --- a/webapp/channels/src/components/air_gapped_contact_sales_modal/index.tsx +++ b/webapp/channels/src/components/air_gapped_contact_sales_modal/index.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import accessProblemImage from 'images/access_problem.svg'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {useDispatch} from 'react-redux'; @@ -12,6 +11,7 @@ import {closeModal} from 'actions/views/modals'; import ExternalLink from 'components/external_link'; +import accessProblemImage from 'images/air_gapped_contact_us_image.png'; import {ModalIdentifiers} from 'utils/constants'; import './style.scss'; diff --git a/webapp/channels/src/components/air_gapped_contact_sales_modal/style.scss b/webapp/channels/src/components/air_gapped_contact_sales_modal/style.scss index 78663355dc..b0243124dd 100644 --- a/webapp/channels/src/components/air_gapped_contact_sales_modal/style.scss +++ b/webapp/channels/src/components/air_gapped_contact_sales_modal/style.scss @@ -6,8 +6,13 @@ display: flex; flex-direction: column; margin-bottom: 26px; -} -.air-gapped-contact-sales-modal-body > .image { - align-self: center; + & > .image { + align-self: center; + + & > img { + max-width: 300px; + height: auto; + } + } } diff --git a/webapp/channels/src/components/analytics/true_up_review.test.tsx b/webapp/channels/src/components/analytics/true_up_review.test.tsx index 5e703b2ae4..60ef3b1d07 100644 --- a/webapp/channels/src/components/analytics/true_up_review.test.tsx +++ b/webapp/channels/src/components/analytics/true_up_review.test.tsx @@ -56,7 +56,7 @@ describe('TrueUpReview', () => { }; it('regular self hosted license in the true up window sees content', () => { - jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available); renderWithContext(, showsTrueUpReviewState); screen.getByText('Share to Mattermost'); @@ -65,7 +65,7 @@ describe('TrueUpReview', () => { it('gov sku self-hosted license does not see true up content', () => { const store = JSON.parse(JSON.stringify(showsTrueUpReviewState)); store.entities.general.license.IsGovSku = 'true'; - jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available); renderWithContext(, store); expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument(); diff --git a/webapp/channels/src/components/analytics/true_up_review.tsx b/webapp/channels/src/components/analytics/true_up_review.tsx index f950d0a747..416b238587 100644 --- a/webapp/channels/src/components/analytics/true_up_review.tsx +++ b/webapp/channels/src/components/analytics/true_up_review.tsx @@ -21,7 +21,7 @@ import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/user import {submitTrueUpReview, getTrueUpReviewStatus} from 'actions/hosted_customer'; import {pageVisited} from 'actions/telemetry_actions'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; +import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from 'components/common/hooks/useCWSAvailabilityCheck'; import ExternalLink from 'components/external_link'; import CheckMarkSvg from 'components/widgets/icons/check_mark_icon'; import WarningIcon from 'components/widgets/icons/fa_warning_icon'; @@ -34,7 +34,8 @@ import './true_up_review.scss'; const TrueUpReview: React.FC = () => { const dispatch = useDispatch(); const isCloud = useSelector(isCurrentLicenseCloud); - const isAirGapped = !useCWSAvailabilityCheck(); + const cwsAvailability = useCWSAvailabilityCheck(); + const isAirGapped = cwsAvailability !== CSWAvailabilityCheckTypes.Available; const reviewProfile = useSelector(trueUpReviewProfileSelector); const reviewStatus = useSelector(trueUpReviewStatusSelector); const isSystemAdmin = useSelector(isCurrentUserSystemAdmin); diff --git a/webapp/channels/src/components/announcement_bar/__snapshots__/announcement_bar.test.tsx.snap b/webapp/channels/src/components/announcement_bar/__snapshots__/announcement_bar.test.tsx.snap index ac61e705c1..9949f2093d 100644 --- a/webapp/channels/src/components/announcement_bar/__snapshots__/announcement_bar.test.tsx.snap +++ b/webapp/channels/src/components/announcement_bar/__snapshots__/announcement_bar.test.tsx.snap @@ -33,9 +33,6 @@ exports[`components/AnnouncementBar should match snapshot, bar not showing 1`] = id="text" /> - @@ -74,9 +71,6 @@ exports[`components/AnnouncementBar should match snapshot, bar showing 1`] = ` id="text" /> - @@ -115,9 +109,6 @@ exports[`components/AnnouncementBar should match snapshot, bar showing, no dismi id="text" /> - @@ -156,9 +147,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 1`] = ` id="text" /> - @@ -197,9 +185,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 2`] = ` id="text" /> - @@ -238,9 +223,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 3`] = ` id="text" /> - @@ -279,9 +261,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 1`] = ` id="text" /> - @@ -320,9 +299,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 2`] = ` id="text" /> - @@ -361,9 +337,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 3`] = ` id="text" /> - @@ -402,9 +375,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 4`] = ` id="text" /> - diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index cf8995ca2f..2df53ce9fd 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -19,15 +19,14 @@ import PaymentAnnouncementBar from './payment_announcement_bar'; import AutoStartTrialModal from './show_start_trial_modal/show_start_trial_modal'; import ShowThreeDaysLeftTrialModal from './show_tree_days_left_trial_modal/show_three_days_left_trial_modal'; import TextDismissableBar from './text_dismissable_bar'; +import UsersLimitsAnnouncementBar from './users_limits_announcement_bar'; import VersionBar from './version_bar'; type Props = { license?: ClientLicense; config?: Partial; canViewSystemErrors: boolean; - isCloud: boolean; userIsAdmin: boolean; - subscription?: Subscription; latestError?: { error: any; }; @@ -103,6 +102,10 @@ class AnnouncementBarController extends React.PureComponent { <> {adminConfiguredAnnouncementBar} {errorBar} + {paymentAnnouncementBar} {cloudTrialAnnouncementBar} {cloudTrialEndAnnouncementBar} diff --git a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx index 12aaef1a41..92cfdc5b5c 100644 --- a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx +++ b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ReactNode} from 'react'; import React from 'react'; import {FormattedMessage} from 'react-intl'; @@ -23,8 +24,8 @@ type Props = { color: string; textColor: string; type: string; - message: React.ReactNode; - tooltipMsg?: React.ReactNode; + message: ReactNode; + tooltipMsg?: ReactNode; handleClose?: (e?: any) => void; showModal?: boolean; announcementBarCount?: number; @@ -32,13 +33,15 @@ type Props = { modalButtonText?: string; modalButtonDefaultText?: string; showLinkAsButton: boolean; - icon?: React.ReactNode; + icon?: ReactNode; warnMetricStatus?: WarnMetricStatus; actions: { incrementAnnouncementBarCount: () => void; decrementAnnouncementBarCount: () => void; }; showCTA?: boolean; + ctaText?: ReactNode; + ctaDisabled?: boolean; } type State = { @@ -189,7 +192,7 @@ export default class AnnouncementBar extends React.PureComponent { {message} { - !this.props.showLinkAsButton && this.props.showCTA && + !this.props.showLinkAsButton && this.props.showCTA && this.props.modalButtonText && this.props.modalButtonDefaultText && {this.props.showModal && { } { - this.props.showLinkAsButton && this.props.showCTA && + this.props.showLinkAsButton && this.props.showCTA && this.props.modalButtonText && this.props.modalButtonDefaultText && } + { + this.props.showLinkAsButton && this.props.showCTA && this.props.ctaText && + + } {closeButton} diff --git a/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.test.ts b/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.test.ts new file mode 100644 index 0000000000..e2110860c5 --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shouldShowUserLimitsAnnouncementBar} from './index'; +import type {ShouldShowingUserLimitsAnnouncementBarProps} from './index'; + +describe('shouldShowUserLimitsAnnouncementBar', () => { + const defaultProps: ShouldShowingUserLimitsAnnouncementBarProps = { + userIsAdmin: true, + isLicensed: false, + maxUsersLimit: 10, + activeUserCount: 5, + }; + + test('should not show when user is not admin', () => { + const props = { + ...defaultProps, + userIsAdmin: false, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); + + test('should not show when active users count is 0', () => { + const props = { + ...defaultProps, + activeUserCount: 0, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); + + test('should not show when max users limit is 0', () => { + const props = { + ...defaultProps, + maxUsersLimit: 0, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); + + test('should not show when active users count is less than max users limit', () => { + const props = { + ...defaultProps, + activeUserCount: 5, + maxUsersLimit: 10, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); + + test('should show when active users count is equal to max users limit', () => { + const props = { + ...defaultProps, + activeUserCount: 10, + maxUsersLimit: 10, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(true); + }); + + test('should show for non licensed servers with active users count is greater than max users limit', () => { + const props = { + ...defaultProps, + isLicensed: false, + activeUserCount: 15, + maxUsersLimit: 10, + }; + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(true); + }); + + test('should not show for licensed server', () => { + const props = { + ...defaultProps, + isLicensed: true, + activeUserCount: 0, + maxUsersLimit: 0, + }; + + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); + + test('should not show for licensed server even if user count is greater than max users limit', () => { + const props = { + ...defaultProps, + isLicensed: true, + activeUserCount: 101, + maxUsersLimit: 100, + }; + + expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.tsx b/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.tsx new file mode 100644 index 0000000000..b29519ee58 --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/users_limits_announcement_bar/index.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {AlertOutlineIcon} from '@mattermost/compass-icons/components'; +import type {ClientLicense} from '@mattermost/types/config'; + +import {getUsersLimits} from 'mattermost-redux/selectors/entities/limits'; + +import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; + +import {AnnouncementBarTypes} from 'utils/constants'; + +type Props = { + license?: ClientLicense; + userIsAdmin: boolean; +}; + +const learnMoreExternalLink = 'https://mattermost.com/pl/error-code-error-user-limits-exceeded'; + +function UsersLimitsAnnouncementBar(props: Props) { + const usersLimits = useSelector(getUsersLimits); + + const handleCTAClick = useCallback(() => { + window.open(learnMoreExternalLink, '_blank'); + }, []); + + const isLicensed = props?.license?.IsLicensed === 'true'; + const maxUsersLimit = usersLimits?.maxUsersLimit ?? 0; + const activeUserCount = usersLimits?.activeUserCount ?? 0; + + if (!shouldShowUserLimitsAnnouncementBar({userIsAdmin: props.userIsAdmin, isLicensed, maxUsersLimit, activeUserCount})) { + return null; + } + + return ( + + } + type={AnnouncementBarTypes.CRITICAL} + icon={} + showCTA={true} + showLinkAsButton={true} + ctaText={ + + } + onButtonClick={handleCTAClick} + /> + ); +} + +export type ShouldShowingUserLimitsAnnouncementBarProps = { + userIsAdmin: boolean; + isLicensed: boolean; + maxUsersLimit: number; + activeUserCount: number; +}; + +export function shouldShowUserLimitsAnnouncementBar({userIsAdmin, isLicensed, maxUsersLimit, activeUserCount}: ShouldShowingUserLimitsAnnouncementBarProps) { + if (!userIsAdmin) { + return false; + } + + if (maxUsersLimit === 0 || activeUserCount === 0) { + return false; + } + + return !isLicensed && activeUserCount >= maxUsersLimit; +} + +export default UsersLimitsAnnouncementBar; diff --git a/webapp/channels/src/components/common/hooks/useCWSAvailabilityCheck.ts b/webapp/channels/src/components/common/hooks/useCWSAvailabilityCheck.ts index 63e3b20e04..4dfc2e8b72 100644 --- a/webapp/channels/src/components/common/hooks/useCWSAvailabilityCheck.ts +++ b/webapp/channels/src/components/common/hooks/useCWSAvailabilityCheck.ts @@ -7,18 +7,35 @@ import {useSelector} from 'react-redux'; import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -export default function useCWSAvailabilityCheck() { - const [canReachCWS, setCanReachCWS] = useState(undefined); +export enum CSWAvailabilityCheckTypes { + Available = 'available', + Unavailable = 'unavailable', + Pending = 'pending', + NotApplicable = 'notApplicable', +} + +export default function useCWSAvailabilityCheck(): CSWAvailabilityCheckTypes { + const [cswAvailability, setCSWAvailability] = useState(CSWAvailabilityCheckTypes.Pending); + const config = useSelector(getConfig); const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; + useEffect(() => { - if (!isEnterpriseReady) { - return; + async function cwsAvailabilityCheck() { + try { + await Client4.cwsAvailabilityCheck(); + setCSWAvailability(CSWAvailabilityCheckTypes.Available); + } catch (error) { + setCSWAvailability(CSWAvailabilityCheckTypes.Unavailable); + } + } + + if (isEnterpriseReady) { + cwsAvailabilityCheck(); + } else { + setCSWAvailability(CSWAvailabilityCheckTypes.NotApplicable); } - Client4.cwsAvailabilityCheck().then(() => { - setCanReachCWS(true); - }).catch(() => setCanReachCWS(false)); }, [isEnterpriseReady]); - return canReachCWS; + return cswAvailability; } diff --git a/webapp/channels/src/components/signup/signup.test.tsx b/webapp/channels/src/components/signup/signup.test.tsx index 0e389dd93e..b73d144461 100644 --- a/webapp/channels/src/components/signup/signup.test.tsx +++ b/webapp/channels/src/components/signup/signup.test.tsx @@ -290,7 +290,7 @@ describe('components/signup/Signup', () => { }); it('should show newsletter check box opt-in for self-hosted non airgapped workspaces', async () => { - jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available); mockLicense = {IsLicensed: 'true', Cloud: 'false'}; const {container: signupContainer} = renderWithContext( @@ -305,7 +305,7 @@ describe('components/signup/Signup', () => { }); it('should NOT show newsletter check box opt-in for self-hosted AND airgapped workspaces', async () => { - jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => false); + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Unavailable); mockLicense = {IsLicensed: 'true', Cloud: 'false'}; const {container: signupContainer} = renderWithContext( @@ -317,7 +317,7 @@ describe('components/signup/Signup', () => { }); it('should show newsletter related opt-in or text for cloud', async () => { - jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true); + jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available); mockLicense = {IsLicensed: 'true', Cloud: 'true'}; const {container: signupContainer} = renderWithContext( diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 87d255b43b..02fca747a1 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -30,7 +30,7 @@ import {getGlobalItem} from 'selectors/storage'; import AlertBanner from 'components/alert_banner'; import type {ModeType, AlertBannerProps} from 'components/alert_banner'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; +import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from 'components/common/hooks/useCWSAvailabilityCheck'; import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg'; import ManWithLaptopSVG from 'components/common/svg_images_components/man_with_laptop_svg'; import DesktopAuthToken from 'components/desktop_auth_token'; @@ -144,7 +144,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const [isMobileView, setIsMobileView] = useState(false); const [subscribeToSecurityNewsletter, setSubscribeToSecurityNewsletter] = useState(false); - const canReachCWS = useCWSAvailabilityCheck(); + const cwsAvailability = useCWSAvailabilityCheck(); const enableExternalSignup = enableSignUpWithGitLab || enableSignUpWithOffice365 || enableSignUpWithGoogle || enableSignUpWithOpenId || enableLDAP || enableSAML; const hasError = Boolean(emailError || nameError || passwordError || serverError || alertBanner); @@ -619,7 +619,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const handleReturnButtonOnClick = () => history.replace('/'); const getNewsletterCheck = () => { - if (canReachCWS) { + if (cwsAvailability === CSWAvailabilityCheckTypes.Available) { return ( (undefined); const {formatMessage} = useIntl(); - const canReachCWS = useCWSAvailabilityCheck(); + const cwsAvailability = useCWSAvailabilityCheck(); const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.START_TRIAL_FORM_MODAL)); const totalUsers = useGetTotalUsersNoBots(true) || 0; const [didOnce, setDidOnce] = useState(false); @@ -236,7 +236,7 @@ function StartTrialFormModal(props: Props): JSX.Element | null { status === TrialLoadStatus.Success ); - if (typeof canReachCWS !== 'undefined' && !canReachCWS) { + if (cwsAvailability === CSWAvailabilityCheckTypes.Unavailable) { return ( { }) as any; // HARRISONTODO Type bindClientFunc } -export function removeLicense(): NewActionFuncAsync { - return bindClientFunc({ - clientFunc: Client4.removeLicense, - }) as any; // HARRISONTODO Type bindClientFunc +export function removeLicense(): NewActionFuncAsync { + return async (dispatch, getState) => { + try { + await Client4.removeLicense(); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + dispatch(logError(error as ServerError)); + return {error: error as ServerError}; + } + + await dispatch(getUsersLimits()); + + return {data: true}; + }; } export function getPrevTrialLicense(): ActionFunc { diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/limits.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/limits.test.ts new file mode 100644 index 0000000000..cc8243e25e --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/limits.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import nock from 'nock'; + +import * as Actions from 'mattermost-redux/actions/limits'; +import {Client4} from 'mattermost-redux/client'; + +import TestHelper from '../../test/test_helper'; +import configureStore from '../../test/test_store'; + +describe('getUsersLimits', () => { + const URL_USERS_LIMITS = '/limits/users'; + + const defaultUserLimitsState = { + activeUserCount: 0, + maxUsersLimit: 0, + }; + + let store = configureStore(); + + beforeAll(() => { + TestHelper.initBasic(Client4); + Client4.setEnableLogging(true); + }); + + beforeEach(() => { + store = configureStore({ + entities: { + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: { + roles: 'system_admin', + }, + }, + }, + }, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + TestHelper.tearDown(); + Client4.setEnableLogging(false); + }); + + test('should return default state for non admin users', async () => { + store = configureStore({ + entities: { + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: { + roles: 'system_user', + }, + }, + }, + }, + }); + + const {data} = await store.dispatch(Actions.getUsersLimits()); + expect(data).toEqual(defaultUserLimitsState); + }); + + test('should not return default state for non admin users', async () => { + const {data} = await store.dispatch(Actions.getUsersLimits()); + expect(data).not.toEqual(defaultUserLimitsState); + }); + + test('should return data if user is admin', async () => { + const userLimits = { + activeUserCount: 600, + maxUsersLimit: 10000, + }; + + nock(Client4.getBaseRoute()). + get(URL_USERS_LIMITS). + reply(200, userLimits); + + const {data} = await store.dispatch(Actions.getUsersLimits()); + expect(data).toEqual(userLimits); + }); + + test('should return error if the request fails', async () => { + const errorMessage = 'test error message'; + nock(Client4.getBaseRoute()). + get(URL_USERS_LIMITS). + reply(400, {message: errorMessage}); + + const {error} = await store.dispatch(Actions.getUsersLimits()); + console.log(error); + expect(error.message).toEqual(errorMessage); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/limits.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/limits.ts new file mode 100644 index 0000000000..3c25e51c10 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/limits.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ServerError} from '@mattermost/types/errors'; +import type {UsersLimits} from '@mattermost/types/limits'; + +import {LimitsTypes} from 'mattermost-redux/action_types'; +import {logError} from 'mattermost-redux/actions/errors'; +import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {Client4} from 'mattermost-redux/client'; +import {getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users'; +import type {NewActionFuncAsync} from 'mattermost-redux/types/actions'; +import {isAdmin} from 'mattermost-redux/utils/user_utils'; + +export function getUsersLimits(): NewActionFuncAsync { + return async (dispatch, getState) => { + const roles = getCurrentUserRoles(getState()); + const amIAdmin = isAdmin(roles); + if (!amIAdmin) { + return { + data: { + activeUserCount: 0, + maxUsersLimit: 0, + }, + }; + } + + let response; + try { + response = await Client4.getUsersLimits(); + } catch (err) { + forceLogoutIfNecessary(err, dispatch, getState); + dispatch(logError(err)); + return {error: err as ServerError}; + } + + const data: UsersLimits = { + activeUserCount: response?.data?.activeUserCount ?? 0, + maxUsersLimit: response?.data?.maxUsersLimit ?? 0, + }; + + dispatch({type: LimitsTypes.RECIEVED_USERS_LIMITS, data}); + + return {data}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts index 1a808dc4dd..2cffb37acf 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts @@ -13,6 +13,7 @@ import {UserTypes, AdminTypes} from 'mattermost-redux/action_types'; import {logError} from 'mattermost-redux/actions/errors'; import {setServerVersion, getClientConfig, getLicenseConfig} from 'mattermost-redux/actions/general'; import {bindClientFunc, forceLogoutIfNecessary, debounce} from 'mattermost-redux/actions/helpers'; +import {getUsersLimits} from 'mattermost-redux/actions/limits'; import {getMyPreferences} from 'mattermost-redux/actions/preferences'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {getMyTeams, getMyTeamMembers, getMyTeamUnreads} from 'mattermost-redux/actions/teams'; @@ -74,6 +75,8 @@ export function loadMe(): ActionFunc { const isCollapsedThreads = isCollapsedThreadsEnabled(getState()); await dispatch(getMyTeamUnreads(isCollapsedThreads)); + + await dispatch(getUsersLimits()); } catch (error) { dispatch(logError(error as ServerError)); return {error: error as ServerError}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts index 59eb3f6ff3..2715e91631 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts @@ -16,6 +16,7 @@ import groups from './groups'; import hostedCustomer from './hosted_customer'; import integrations from './integrations'; import jobs from './jobs'; +import limits from './limits'; import posts from './posts'; import preferences from './preferences'; import roles from './roles'; @@ -30,6 +31,7 @@ import users from './users'; export default combineReducers({ general, users, + limits, teams, channels, posts, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/limits.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/limits.ts new file mode 100644 index 0000000000..573f5d9bae --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/limits.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {combineReducers} from 'redux'; + +import {LimitsTypes} from 'mattermost-redux/action_types'; +import type {GenericAction} from 'mattermost-redux/types/actions'; + +function usersLimits(state = {}, action: GenericAction) { + switch (action.type) { + case LimitsTypes.RECIEVED_USERS_LIMITS: { + const usersLimits = action.data; + return { + ...state, + ...usersLimits, + }; + } + default: + return state; + } +} + +export default combineReducers({ + usersLimits, +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/limits.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/limits.ts new file mode 100644 index 0000000000..d8db2cddf3 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/limits.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {UsersLimits} from '@mattermost/types/limits'; +import type {GlobalState} from '@mattermost/types/store'; + +export function getUsersLimits(state: GlobalState): UsersLimits { + return state.entities.limits.usersLimits; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts index ea360c73e2..03ff4a85ba 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts @@ -162,7 +162,7 @@ export const currentUserHasAnAdminRole: (state: GlobalState) => boolean = create }, ); -export const getCurrentUserRoles: (a: GlobalState) => UserProfile['roles'] = createSelector( +export const getCurrentUserRoles: (_: GlobalState) => UserProfile['roles'] = createSelector( 'getCurrentUserRoles', getMyCurrentChannelMembership, (state) => state.entities.teams.myMembers[state.entities.teams.currentTeamId], diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index 7fb299611a..33388b13a1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -35,6 +35,12 @@ const state: GlobalState = { myUserAccessTokens: {}, lastActivity: {}, }, + limits: { + usersLimits: { + activeUserCount: 0, + maxUsersLimit: 0, + }, + }, teams: { currentTeamId: '', teams: {}, diff --git a/webapp/channels/src/sass/components/_announcement-bar.scss b/webapp/channels/src/sass/components/_announcement-bar.scss index 3869282ffb..d06163241f 100644 --- a/webapp/channels/src/sass/components/_announcement-bar.scss +++ b/webapp/channels/src/sass/components/_announcement-bar.scss @@ -104,7 +104,6 @@ height: 24px; box-sizing: border-box; padding: 4px 8px; - padding-top: 2px; border: 1px solid #fff; margin-left: 8px; background-color: inherit !important; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 13ce25917d..1f183d60b3 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -148,6 +148,7 @@ import { } from '@mattermost/types/data_retention'; import {CompleteOnboardingRequest} from '@mattermost/types/setup'; import {UserThreadList, UserThread, UserThreadWithPost} from '@mattermost/types/threads'; +import {UsersLimits} from '@mattermost/types/limits'; import {cleanUrlForLogging} from './errors'; import {buildQueryString} from './helpers'; @@ -488,6 +489,10 @@ export default class Client4 { return `${this.getBaseRoute()}/limits`; } + getUsersLimitsRoute() { + return `${this.getLimitsRoute()}/users`; + } + getCSRFFromCookie() { if (typeof document !== 'undefined' && typeof document.cookie !== 'undefined') { const cookies = document.cookie.split(';'); @@ -1196,6 +1201,18 @@ export default class Client4 { ); } + + // Limits Routes + + getUsersLimits = () => { + return this.doFetchWithResponse( + `${this.getUsersLimitsRoute()}`, + { + method: 'get', + }, + ); + } + // Team Routes createTeam = (team: Team) => { diff --git a/webapp/platform/types/src/limits.ts b/webapp/platform/types/src/limits.ts new file mode 100644 index 0000000000..1d8d5b60e3 --- /dev/null +++ b/webapp/platform/types/src/limits.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type LimitsState = { + usersLimits: UsersLimits; +}; + +export type UsersLimits = { + activeUserCount: number; + maxUsersLimit: number; +}; diff --git a/webapp/platform/types/src/store.ts b/webapp/platform/types/src/store.ts index c2f6a0c14d..8fcbe01a4a 100644 --- a/webapp/platform/types/src/store.ts +++ b/webapp/platform/types/src/store.ts @@ -29,11 +29,13 @@ import {ThreadsState} from './threads'; import {Typing} from './typing'; import {UsersState} from './users'; import {AppsState} from './apps'; +import {LimitsState} from './limits'; export type GlobalState = { entities: { general: GeneralState; users: UsersState; + limits: LimitsState; teams: TeamsState; channels: ChannelsState; posts: PostsState;