[MM-56318] Global warning banners of user limit for admins (#25797)

* get data to store

* inin

* added inside loadme

* useCWSAvailabilityCheck hook improve

* modal airgap

* the bar

* Update announcement_bar_controller.tsx

* Update true_up_review.tsx

* csw

* fixes

* fixed

* more tests

* icon changed

* changes

* Extended tests

* Update index.ts

* redux harrison changes

* Updated copy

* Lint fixes

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: harshil Sharma <harshilsharma63@gmail.com>
This commit is contained in:
M-ZubairAhmed
2024-01-11 14:11:04 +00:00
committed by GitHub
parent 539412b353
commit 2676caa52f
34 changed files with 503 additions and 72 deletions

View File

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

View File

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

View File

@@ -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<ActionResult>;
removeLicense: () => Promise<ActionResult>;
removeLicense: () => Promise<ActionResult<boolean, ServerError>>;
getPrevTrialLicense: () => void;
upgradeToE0: () => Promise<StatusOK>;
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<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
getUsersLimits: () => Promise<ActionResult<UsersLimits, ServerError>>;
getFilteredUsersStats: (filters: GetFilteredUsersStatsOpts) => Promise<{
data?: UsersStats;
error?: ServerError;
@@ -179,8 +181,13 @@ export default class LicenseSettings extends React.PureComponent<Props, State> {
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});
};

View File

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

View File

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

View File

@@ -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(<TrueUpReview/>, 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(<TrueUpReview/>, store);
expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument();

View File

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

View File

@@ -33,9 +33,6 @@ exports[`components/AnnouncementBar should match snapshot, bar not showing 1`] =
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -74,9 +71,6 @@ exports[`components/AnnouncementBar should match snapshot, bar showing 1`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -115,9 +109,6 @@ exports[`components/AnnouncementBar should match snapshot, bar showing, no dismi
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -156,9 +147,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 1`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -197,9 +185,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 2`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -238,9 +223,6 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 3`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -279,9 +261,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 1`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -320,9 +299,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 2`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -361,9 +337,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 3`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>
@@ -402,9 +375,6 @@ exports[`components/AnnouncementBar should match snapshot, props change 4`] = `
id="text"
/>
</span>
<span
className="announcement-bar__link"
/>
</div>
</OverlayTrigger>
</_StyledDiv>

View File

@@ -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<ClientConfig>;
canViewSystemErrors: boolean;
isCloud: boolean;
userIsAdmin: boolean;
subscription?: Subscription;
latestError?: {
error: any;
};
@@ -103,6 +102,10 @@ class AnnouncementBarController extends React.PureComponent<Props> {
<>
{adminConfiguredAnnouncementBar}
{errorBar}
<UsersLimitsAnnouncementBar
license={this.props.license}
userIsAdmin={this.props.userIsAdmin}
/>
{paymentAnnouncementBar}
{cloudTrialAnnouncementBar}
{cloudTrialEndAnnouncementBar}

View File

@@ -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<Props, State> {
{message}
</span>
{
!this.props.showLinkAsButton && this.props.showCTA &&
!this.props.showLinkAsButton && this.props.showCTA && this.props.modalButtonText && this.props.modalButtonDefaultText &&
<span className='announcement-bar__link'>
{this.props.showModal &&
<FormattedMessage
@@ -216,10 +219,10 @@ export default class AnnouncementBar extends React.PureComponent<Props, State> {
</span>
}
{
this.props.showLinkAsButton && this.props.showCTA &&
this.props.showLinkAsButton && this.props.showCTA && this.props.modalButtonText && this.props.modalButtonDefaultText &&
<button
className='upgrade-button'
onClick={this.props.onButtonClick}
disabled={this.props.ctaDisabled}
>
<FormattedMessage
id={this.props.modalButtonText}
@@ -227,6 +230,15 @@ export default class AnnouncementBar extends React.PureComponent<Props, State> {
/>
</button>
}
{
this.props.showLinkAsButton && this.props.showCTA && this.props.ctaText &&
<button
onClick={this.props.onButtonClick}
disabled={this.props.ctaDisabled}
>
{this.props.ctaText}
</button>
}
</div>
</OverlayTrigger>
{closeButton}

View File

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

View File

@@ -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 (
<AnnouncementBar
id='users_limits_announcement_bar'
showCloseButton={false}
message={
<FormattedMessage
id='users_limits_announcement_bar.copyText'
defaultMessage='User limits exceeded. Contact administrator with: ERROR_USER_LIMITS_EXCEEDED'
/>
}
type={AnnouncementBarTypes.CRITICAL}
icon={<AlertOutlineIcon size={16}/>}
showCTA={true}
showLinkAsButton={true}
ctaText={
<FormattedMessage
id='users_limits_announcement_bar.ctaText'
defaultMessage='Learn More'
/>
}
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;

View File

@@ -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<boolean | undefined>(undefined);
export enum CSWAvailabilityCheckTypes {
Available = 'available',
Unavailable = 'unavailable',
Pending = 'pending',
NotApplicable = 'notApplicable',
}
export default function useCWSAvailabilityCheck(): CSWAvailabilityCheckTypes {
const [cswAvailability, setCSWAvailability] = useState<CSWAvailabilityCheckTypes>(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;
}

View File

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

View File

@@ -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 (
<CheckInput
id='signup-body-card-form-check-newsletter'

View File

@@ -18,7 +18,7 @@ import {closeModal, openModal} from 'actions/views/modals';
import {isModalOpen} from 'selectors/views/modals';
import {makeAsyncComponent} from 'components/async_load';
import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck';
import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from 'components/common/hooks/useCWSAvailabilityCheck';
import useGetTotalUsersNoBots from 'components/common/hooks/useGetTotalUsersNoBots';
import DropdownInput from 'components/dropdown_input';
import ExternalLink from 'components/external_link';
@@ -78,7 +78,7 @@ function StartTrialFormModal(props: Props): JSX.Element | null {
const [country, setCountry] = useState('');
const [businessEmailError, setBusinessEmailError] = useState<CustomMessageInputType | undefined>(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 (
<AirGappedModal
onClose={handleOnClose}

View File

@@ -5726,6 +5726,8 @@
"userGuideHelp.mattermostUserGuide": "Mattermost user guide",
"userGuideHelp.reportAProblem": "Report a problem",
"userGuideHelp.trainingResources": "Training resources",
"users_limits_announcement_bar.copyText": "User limits exceeded. Contact administrator with: ERROR_USER_LIMITS_EXCEEDED",
"users_limits_announcement_bar.ctaText": "Learn More",
"userSettingsModal.pluginPreferences.header": "PLUGIN PREFERENCES",
"version_bar.new": "A new version of Mattermost is available.",
"version_bar.refresh": "Refresh the app now",

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -16,6 +16,7 @@ import GroupTypes from './groups';
import HostedCustomerTypes from './hosted_customer';
import IntegrationTypes from './integrations';
import JobTypes from './jobs';
import LimitsTypes from './limits';
import PlaybookType from './playbooks';
import PluginTypes from './plugins';
import PostTypes from './posts';
@@ -40,6 +41,7 @@ export {
EmojiTypes,
AdminTypes,
JobTypes,
LimitsTypes,
SearchTypes,
RoleTypes,
SchemeTypes,

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECIEVED_USERS_LIMITS: null,
});

View File

@@ -65,6 +65,7 @@ export default keyMirror({
DISABLED_USER_ACCESS_TOKEN: null,
ENABLED_USER_ACCESS_TOKEN: null,
RECEIVED_USER_STATS: null,
RECIEVED_USERS_LIMITS: null,
RECEIVED_FILTERED_USER_STATS: null,
PROFILE_NO_LONGER_VISIBLE: null,
LOGIN: null,

View File

@@ -27,6 +27,7 @@ import type {
} from '@mattermost/types/teams';
import {AdminTypes} from 'mattermost-redux/action_types';
import {getUsersLimits} from 'mattermost-redux/actions/limits';
import {Client4} from 'mattermost-redux/client';
import type {ActionFunc, DispatchFunc, GetStateFunc, NewActionFuncAsync} from 'mattermost-redux/types/actions';
@@ -373,10 +374,20 @@ export function uploadLicense(fileData: File): NewActionFuncAsync<License> {
}) as any; // HARRISONTODO Type bindClientFunc
}
export function removeLicense(): NewActionFuncAsync {
return bindClientFunc({
clientFunc: Client4.removeLicense,
}) as any; // HARRISONTODO Type bindClientFunc
export function removeLicense(): NewActionFuncAsync<boolean> {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,12 @@ const state: GlobalState = {
myUserAccessTokens: {},
lastActivity: {},
},
limits: {
usersLimits: {
activeUserCount: 0,
maxUsersLimit: 0,
},
},
teams: {
currentTeamId: '',
teams: {},

View File

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

View File

@@ -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<UsersLimits>(
`${this.getUsersLimitsRoute()}`,
{
method: 'get',
},
);
}
// Team Routes
createTeam = (team: Team) => {

View File

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

View File

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