From 072c402e4908e628bdbe2e912cb2bcc29ccf388f Mon Sep 17 00:00:00 2001 From: Maria A Nunez Date: Mon, 15 Sep 2025 11:39:52 -0400 Subject: [PATCH] Team Edition User Limit Update (#33888) * Lower user limit for TE to final limit * Added warning banner for user soft limit * Linting * Fix test * Fix tests --- server/channels/api4/limits_test.go | 4 +- server/channels/app/limits.go | 4 +- server/channels/app/limits_test.go | 14 +- .../announcement_bar.tsx | 2 + .../index.test.ts | 185 +++++++++++++----- .../users_limits_announcement_bar/index.tsx | 147 ++++++++++---- webapp/channels/src/i18n/en.json | 1 + .../sass/components/_announcement-bar.scss | 12 ++ webapp/channels/src/utils/constants.tsx | 2 + 9 files changed, 282 insertions(+), 89 deletions(-) diff --git a/server/channels/api4/limits_test.go b/server/channels/api4/limits_test.go index 39d7cc75bde..437287fe231 100644 --- a/server/channels/api4/limits_test.go +++ b/server/channels/api4/limits_test.go @@ -29,8 +29,8 @@ func TestGetServerLimits(t *testing.T) { // Should have full access to all limits data require.Greater(t, serverLimits.ActiveUserCount, int64(0)) - require.Equal(t, int64(2500), serverLimits.MaxUsersLimit) - require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit) + require.Equal(t, int64(200), serverLimits.MaxUsersLimit) + require.Equal(t, int64(250), serverLimits.MaxUsersHardLimit) require.Equal(t, int64(0), serverLimits.PostHistoryLimit) require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime) }) diff --git a/server/channels/app/limits.go b/server/channels/app/limits.go index 430a124f315..b13103898a8 100644 --- a/server/channels/app/limits.go +++ b/server/channels/app/limits.go @@ -10,8 +10,8 @@ import ( ) const ( - maxUsersLimit = 2_500 - maxUsersHardLimit = 5_000 + maxUsersLimit = 200 + maxUsersHardLimit = 250 ) func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) { diff --git a/server/channels/app/limits_test.go b/server/channels/app/limits_test.go index d06d8684efd..10f9364202c 100644 --- a/server/channels/app/limits_test.go +++ b/server/channels/app/limits_test.go @@ -28,8 +28,8 @@ func TestGetServerLimits(t *testing.T) { // InitBasic creates 3 users by default require.Equal(t, int64(3), serverLimits.ActiveUserCount) - require.Equal(t, int64(2500), serverLimits.MaxUsersLimit) - require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit) + require.Equal(t, int64(200), serverLimits.MaxUsersLimit) + require.Equal(t, int64(250), serverLimits.MaxUsersHardLimit) }) t.Run("user count should increase on creating new user and decrease on permanently deleting", func(t *testing.T) { @@ -279,7 +279,7 @@ func TestIsAtUserLimit(t *testing.T) { th.App.Srv().SetLicense(nil) mockUserStore := storemocks.UserStore{} - mockUserStore.On("Count", mock.Anything).Return(int64(4000), nil) // Under hard limit of 5000 + mockUserStore.On("Count", mock.Anything).Return(int64(200), nil) // Under hard limit of 250 mockStore := th.App.Srv().Store().(*storemocks.Store) mockStore.On("User").Return(&mockUserStore) @@ -295,7 +295,7 @@ func TestIsAtUserLimit(t *testing.T) { th.App.Srv().SetLicense(nil) mockUserStore := storemocks.UserStore{} - mockUserStore.On("Count", mock.Anything).Return(int64(5000), nil) // At hard limit of 5000 + mockUserStore.On("Count", mock.Anything).Return(int64(250), nil) // At hard limit of 250 mockStore := th.App.Srv().Store().(*storemocks.Store) mockStore.On("User").Return(&mockUserStore) @@ -311,7 +311,7 @@ func TestIsAtUserLimit(t *testing.T) { th.App.Srv().SetLicense(nil) mockUserStore := storemocks.UserStore{} - mockUserStore.On("Count", mock.Anything).Return(int64(6000), nil) // Over hard limit of 5000 + mockUserStore.On("Count", mock.Anything).Return(int64(300), nil) // Over hard limit of 250 mockStore := th.App.Srv().Store().(*storemocks.Store) mockStore.On("User").Return(&mockUserStore) @@ -547,8 +547,8 @@ func TestExtraUsersBehavior(t *testing.T) { require.Nil(t, appErr) // Unlicensed servers use hard-coded limits without extra users - require.Equal(t, int64(2500), serverLimits.MaxUsersLimit) - require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit) + require.Equal(t, int64(200), serverLimits.MaxUsersLimit) + require.Equal(t, int64(250), serverLimits.MaxUsersHardLimit) }) } 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 d8f4debdd24..3678eb4565a 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 @@ -149,6 +149,8 @@ export default class AnnouncementBar extends React.PureComponent { barClass = 'announcement-bar announcement-bar-advisor-ack'; } else if (this.props.type === AnnouncementBarTypes.GENERAL) { barClass = 'announcement-bar announcement-bar-general'; + } else if (this.props.type === AnnouncementBarTypes.WARNING) { + barClass = 'announcement-bar announcement-bar-warning'; } if (this.props.className) { 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 index e2110860c52..ee5f7304ae0 100644 --- 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 @@ -1,88 +1,185 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shouldShowUserLimitsAnnouncementBar} from './index'; +import {shouldShowCriticalBanner, shouldShowWarningBanner} from './index'; import type {ShouldShowingUserLimitsAnnouncementBarProps} from './index'; -describe('shouldShowUserLimitsAnnouncementBar', () => { - const defaultProps: ShouldShowingUserLimitsAnnouncementBarProps = { +describe('shouldShowCriticalBanner', () => { + const defaultCriticalProps: ShouldShowingUserLimitsAnnouncementBarProps = { userIsAdmin: true, isLicensed: false, - maxUsersLimit: 10, - activeUserCount: 5, + maxUsersHardLimit: 20, + activeUserCount: 15, }; test('should not show when user is not admin', () => { const props = { - ...defaultProps, + ...defaultCriticalProps, userIsAdmin: false, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + expect(shouldShowCriticalBanner(props)).toBe(false); }); test('should not show when active users count is 0', () => { const props = { - ...defaultProps, + ...defaultCriticalProps, activeUserCount: 0, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + expect(shouldShowCriticalBanner(props)).toBe(false); }); - test('should not show when max users limit is 0', () => { + test('should not show when max users hard limit is 0', () => { const props = { - ...defaultProps, - maxUsersLimit: 0, + ...defaultCriticalProps, + maxUsersHardLimit: 0, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + expect(shouldShowCriticalBanner(props)).toBe(false); }); - test('should not show when active users count is less than max users limit', () => { + test('should not show when active users count is less than max users hard 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, + ...defaultCriticalProps, activeUserCount: 15, - maxUsersLimit: 10, + maxUsersHardLimit: 20, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(true); + expect(shouldShowCriticalBanner(props)).toBe(false); + }); + + test('should show when active users count is equal to max users hard limit', () => { + const props = { + ...defaultCriticalProps, + activeUserCount: 20, + maxUsersHardLimit: 20, + }; + expect(shouldShowCriticalBanner(props)).toBe(true); + }); + + test('should show for non licensed servers with active users count is greater than max users hard limit', () => { + const props = { + ...defaultCriticalProps, + isLicensed: false, + activeUserCount: 25, + maxUsersHardLimit: 20, + }; + expect(shouldShowCriticalBanner(props)).toBe(true); }); test('should not show for licensed server', () => { const props = { - ...defaultProps, + ...defaultCriticalProps, isLicensed: true, activeUserCount: 0, - maxUsersLimit: 0, + maxUsersHardLimit: 0, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + expect(shouldShowCriticalBanner(props)).toBe(false); }); - test('should not show for licensed server even if user count is greater than max users limit', () => { + test('should not show for licensed server even if user count is greater than max users hard limit', () => { const props = { - ...defaultProps, + ...defaultCriticalProps, isLicensed: true, - activeUserCount: 101, - maxUsersLimit: 100, + activeUserCount: 25, + maxUsersHardLimit: 20, }; - expect(shouldShowUserLimitsAnnouncementBar(props)).toBe(false); + expect(shouldShowCriticalBanner(props)).toBe(false); + }); +}); + +describe('shouldShowWarningBanner', () => { + const defaultWarningProps: ShouldShowingUserLimitsAnnouncementBarProps = { + userIsAdmin: true, + isLicensed: false, + maxUsersLimit: 10, + maxUsersHardLimit: 20, + activeUserCount: 12, + isWarningDismissed: false, + }; + + test('should not show when user is not admin', () => { + const props = { + ...defaultWarningProps, + userIsAdmin: false, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when active users count is 0', () => { + const props = { + ...defaultWarningProps, + activeUserCount: 0, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when max users limit is 0', () => { + const props = { + ...defaultWarningProps, + maxUsersLimit: 0, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when max users hard limit is 0', () => { + const props = { + ...defaultWarningProps, + maxUsersHardLimit: 0, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when warning is dismissed', () => { + const props = { + ...defaultWarningProps, + isWarningDismissed: true, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when active users count is less than max users limit', () => { + const props = { + ...defaultWarningProps, + activeUserCount: 8, + maxUsersLimit: 10, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should not show when active users count is greater than or equal to max users hard limit', () => { + const props = { + ...defaultWarningProps, + activeUserCount: 20, + maxUsersHardLimit: 20, + }; + expect(shouldShowWarningBanner(props)).toBe(false); + }); + + test('should show when active users count is between max users limit and max users hard limit', () => { + const props = { + ...defaultWarningProps, + activeUserCount: 15, + maxUsersLimit: 10, + maxUsersHardLimit: 20, + }; + expect(shouldShowWarningBanner(props)).toBe(true); + }); + + test('should show when active users count equals max users limit', () => { + const props = { + ...defaultWarningProps, + activeUserCount: 10, + maxUsersLimit: 10, + maxUsersHardLimit: 20, + }; + expect(shouldShowWarningBanner(props)).toBe(true); + }); + + test('should not show for licensed server', () => { + const props = { + ...defaultWarningProps, + isLicensed: true, + }; + expect(shouldShowWarningBanner(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 index bd5fdcd28b7..314f83b60eb 100644 --- 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 @@ -3,16 +3,22 @@ import React, {useCallback} from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useDispatch, useSelector} from 'react-redux'; import {AlertOutlineIcon} from '@mattermost/compass-icons/components'; import type {ClientLicense} from '@mattermost/types/config'; +import type {PreferenceType} from '@mattermost/types/preferences'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; import {getServerLimits} from 'mattermost-redux/selectors/entities/limits'; +import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; -import {AnnouncementBarTypes} from 'utils/constants'; +import {AnnouncementBarTypes, Preferences} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; type Props = { license?: ClientLicense; @@ -22,7 +28,9 @@ type Props = { const learnMoreExternalLink = 'https://mattermost.com/pl/error-code-error-safety-limits-exceeded'; function UsersLimitsAnnouncementBar(props: Props) { + const dispatch = useDispatch(); const serverLimits = useSelector(getServerLimits); + const currentUser = useSelector(getCurrentUser); const handleCTAClick = useCallback(() => { window.open(learnMoreExternalLink, '_blank'); @@ -30,57 +38,128 @@ function UsersLimitsAnnouncementBar(props: Props) { const isLicensed = props?.license?.IsLicensed === 'true'; const maxUsersLimit = serverLimits?.maxUsersLimit ?? 0; + const maxUsersHardLimit = serverLimits?.maxUsersHardLimit ?? 0; const activeUserCount = serverLimits?.activeUserCount ?? 0; - if (!shouldShowUserLimitsAnnouncementBar({userIsAdmin: props.userIsAdmin, isLicensed, maxUsersLimit, activeUserCount})) { - return null; + // Check if warning banner has been dismissed + const warningDismissalKey = 'users_limits_warning'; + const isWarningDismissed = useSelector((state: GlobalState) => { + return getPreference(state, Preferences.USERS_LIMITS_BANNER, warningDismissalKey, 'false') === 'true'; + }); + + const handleWarningDismiss = useCallback(() => { + if (currentUser?.id) { + const preference: PreferenceType = { + category: Preferences.USERS_LIMITS_BANNER, + name: warningDismissalKey, + user_id: currentUser.id, + value: 'true', + }; + dispatch(savePreferences(currentUser.id, [preference])); + } + }, [currentUser?.id, dispatch, warningDismissalKey]); + + // Critical state: activeUserCount >= hardLimit + if (shouldShowCriticalBanner({userIsAdmin: props.userIsAdmin, isLicensed, maxUsersHardLimit, activeUserCount})) { + return ( + + } + type={AnnouncementBarTypes.CRITICAL} + icon={} + showCTA={true} + showLinkAsButton={true} + ctaText={ + + } + onButtonClick={handleCTAClick} + /> + ); } - return ( - - } - type={AnnouncementBarTypes.CRITICAL} - icon={} - showCTA={true} - showLinkAsButton={true} - ctaText={ - - } - onButtonClick={handleCTAClick} - /> - ); + // Warning state: activeUserCount >= maxLimit && < hardLimit + if (shouldShowWarningBanner({userIsAdmin: props.userIsAdmin, isLicensed, maxUsersLimit, maxUsersHardLimit, activeUserCount, isWarningDismissed})) { + return ( + + } + type='warning' + icon={} + showCTA={true} + showLinkAsButton={true} + ctaText={ + + } + onButtonClick={handleCTAClick} + /> + ); + } + + return null; } export type ShouldShowingUserLimitsAnnouncementBarProps = { userIsAdmin: boolean; isLicensed: boolean; - maxUsersLimit: number; + maxUsersLimit?: number; + maxUsersHardLimit: number; activeUserCount: number; + isWarningDismissed?: boolean; }; -export function shouldShowUserLimitsAnnouncementBar({userIsAdmin, isLicensed, maxUsersLimit, activeUserCount}: ShouldShowingUserLimitsAnnouncementBarProps) { +export function shouldShowCriticalBanner({userIsAdmin, isLicensed, maxUsersHardLimit, activeUserCount}: ShouldShowingUserLimitsAnnouncementBarProps) { if (!userIsAdmin) { return false; } - if (maxUsersLimit === 0 || activeUserCount === 0) { + if (maxUsersHardLimit === 0 || activeUserCount === 0) { return false; } - return !isLicensed && activeUserCount >= maxUsersLimit; + return !isLicensed && activeUserCount >= maxUsersHardLimit; +} + +export function shouldShowWarningBanner({userIsAdmin, isLicensed, maxUsersLimit = 0, maxUsersHardLimit, activeUserCount, isWarningDismissed}: ShouldShowingUserLimitsAnnouncementBarProps) { + if (!userIsAdmin) { + return false; + } + + if (maxUsersLimit === 0 || maxUsersHardLimit === 0 || activeUserCount === 0) { + return false; + } + + if (isWarningDismissed) { + return false; + } + + // Show warning when users >= maxUsersLimit but < maxUsersHardLimit + return !isLicensed && activeUserCount >= maxUsersLimit && activeUserCount < maxUsersHardLimit; } export default UsersLimitsAnnouncementBar; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index f23b9b92d9e..5a3af0d4e08 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -6438,6 +6438,7 @@ "userGuideHelp.trainingResources": "Training resources", "users_limits_announcement_bar.copyText": "User limits exceeded. Contact administrator with: {ErrorCode}", "users_limits_announcement_bar.ctaText": "Learn More", + "users_limits_announcement_bar.warning.copyText": "This workspace is approaching the user limit ({activeUserCount}/{maxUsersHardLimit} users).", "userSettings.adminMode.admin_mode_badge": "Admin Mode", "userSettings.adminMode.modal_header": "Manage {userDisplayName}'s Settings", "userSettingsModal.pluginPreferences.header": "PLUGIN PREFERENCES", diff --git a/webapp/channels/src/sass/components/_announcement-bar.scss b/webapp/channels/src/sass/components/_announcement-bar.scss index 6bb3c56e931..9580b63da8a 100644 --- a/webapp/channels/src/sass/components/_announcement-bar.scss +++ b/webapp/channels/src/sass/components/_announcement-bar.scss @@ -121,6 +121,18 @@ background-color: #3d3c40; } +.announcement-bar-warning { + background-color: var(--yellow-400); + color: var(--neutral-900); + .announcement-bar__text { + .btn.btn-tertiary.btn-xs.btn-inverted { + border-color: var(--neutral-900); + + color: var(--neutral-900); + } + } +} + .announcement-bar__link { margin-left: 4px; } diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index abe7664f278..281d8efcd93 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -151,6 +151,7 @@ export const Preferences = { NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE: 'admin_revoke_downgraded_instance', OVERAGE_USERS_BANNER: ReduxPreferences.CATEGORY_OVERAGE_USERS_BANNER, POST_HISTORY_LIMIT_BANNER: ReduxPreferences.CATEGORY_POST_HISTORY_LIMIT_BANNER, + USERS_LIMITS_BANNER: 'users_limits_banner', TO_CLOUD_YEARLY_PLAN_NUDGE: 'to_cloud_yearly_plan_nudge', TO_PAID_PLAN_NUDGE: 'to_paid_plan_nudge', CLOUD_ANNUAL_RENEWAL_BANNER: 'cloud_annual_renewal_banner', @@ -932,6 +933,7 @@ export const AnnouncementBarTypes = { ADVISOR: 'advisor', ADVISOR_ACK: 'advisor-ack', GENERAL: 'general', + WARNING: 'warning', }; export const AnnouncementBarMessages = {