diff --git a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap index ec0e06b16c..1de52df271 100644 --- a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap @@ -19,6 +19,22 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a
+ + + + + + + + + + + + +
+ > + +
@@ -1616,7 +1809,25 @@ exports[`components/admin_console/license_settings/LicenseSettings should match >
+ > + +
@@ -1725,6 +1936,22 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
+ { - const minWarning = Math.ceil(parseInt(license.Users, 10) * OverActiveUserLimits.MIN) + parseInt(license.Users, 10); - const props = { - ...baseProps, - statsActiveUsers: minWarning, - }; - - renderWithContext( - , - initialState, - ); - - expect(screen.getByText(Intl.NumberFormat('en').format(minWarning))).toHaveClass('value'); - expect(screen.getByText(Intl.NumberFormat('en').format(minWarning))).toHaveClass('value--warning-over-seats-purchased'); - expect(screen.getByText(Intl.NumberFormat('en').format(minWarning))).not.toHaveClass('value--over-seats-purchased'); - expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend'); - expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--warning-over-seats-purchased'); - expect(screen.getByText('ACTIVE USERS:')).not.toHaveClass('legend--over-seats-purchased'); - }); - test('should add over-seats-purchased class to active users', () => { - const exceedHighLimitExtraUsersError = Math.ceil(parseInt(license.Users, 10) * OverActiveUserLimits.MAX) + parseInt(license.Users, 10); + // Changed to not use the constant OverActiveUserLimits.MAX given that we are currently set to 0. So the active users will be 0 + const exceedHighLimitExtraUsersError = Math.ceil(parseInt(license.Users, 10) * 0.2) + parseInt(license.Users, 10); const props = { ...baseProps, statsActiveUsers: exceedHighLimitExtraUsersError, 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 b3aac51e8a..0a8e228953 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 @@ -34,6 +34,7 @@ import TeamEditionLeftPanel from './team_edition/team_edition_left_panel'; import TeamEditionRightPanel from './team_edition/team_edition_right_panel'; import TrialBanner from './trial_banner/trial_banner'; import TrialLicenseCard from './trial_license_card/trial_license_card'; +import UserSeatAlertBanner from './user_seat_alert_banner'; import './license_settings.scss'; @@ -354,6 +355,11 @@ export default class LicenseSettings extends React.PureComponent {
+ {!this.state.clickNormalUpgradeBtn && license.IsLicensed !== 'true' && this.props.prevTrialLicense?.IsLicensed !== 'true' && = ({license, totalUsers, location}) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + const currentUser = useSelector(getCurrentUser); + + const getPreferenceName = (percentUsed: number) => { + if (percentUsed < 90) { + return ''; + } + + const locationSuffix = location === 'license_settings' ? 'license_settings' : 'system_statistics'; + if (percentUsed >= 100) { + return `100_seat_${locationSuffix}`; + } else if (percentUsed >= 95) { + return `95_seat_${locationSuffix}`; + } + return `90_seat_${locationSuffix}`; + }; + + const calculatePercentUsed = () => { + if (!license || !license.Users) { + return 0; + } + const licensedUsers = parseInt(license.Users, 10); + + if (!licensedUsers || licensedUsers === 0) { + return 0; + } + return (totalUsers / licensedUsers) * 100; + }; + + const percentUsed = calculatePercentUsed(); + const preferenceName = getPreferenceName(percentUsed); + const dismissed = useSelector((state: GlobalState) => selectPreference(state, Preferences.CATEGORY_SYSTEM_NOTICE, preferenceName, 'false')); + const [visible, setVisible] = useState(dismissed === 'false'); + + useEffect(() => { + setVisible(dismissed === 'false'); + }, [dismissed]); + + const handleDismiss = () => { + setVisible(false); + + dispatch(savePreferences(currentUser.id, [{ + user_id: currentUser.id, + category: Preferences.CATEGORY_SYSTEM_NOTICE, + name: preferenceName, + value: 'true', + }])); + }; + + if (!visible || !license || !license.Users || percentUsed < 90) { + return null; + } + + let mode: 'success' | 'info' | 'danger' = 'info'; + let title: React.ReactNode = null; + let message: React.ReactNode = null; + + if (percentUsed >= 90 && percentUsed < 95) { + mode = 'success'; + title = ( + + ); + message = ( + + ); + } else if (percentUsed >= 95 && percentUsed < 100) { + mode = 'info'; + title = ( + + ); + message = ( + + ); + } else if (percentUsed >= 100) { + mode = 'danger'; + title = ( + + ); + message = ( + + ); + } + + const actionButtonLeft = ( + + + + ); + + return ( + + ); +}; + +export default UserSeatAlertBanner; diff --git a/webapp/channels/src/components/alert_banner/alert_banner.scss b/webapp/channels/src/components/alert_banner/alert_banner.scss index 0113d7e89c..3c6325d364 100644 --- a/webapp/channels/src/components/alert_banner/alert_banner.scss +++ b/webapp/channels/src/components/alert_banner/alert_banner.scss @@ -49,7 +49,8 @@ } .AlertBanner__actionButtons { - margin-top: 12px; + margin-top: 20px; + margin-bottom: 16px; font-family: 'Open Sans'; .AlertBanner__buttonLeft { diff --git a/webapp/channels/src/components/analytics/activated_users_card/index.tsx b/webapp/channels/src/components/analytics/activated_users_card/index.tsx index e93a57d632..e9266ce8e7 100644 --- a/webapp/channels/src/components/analytics/activated_users_card/index.tsx +++ b/webapp/channels/src/components/analytics/activated_users_card/index.tsx @@ -53,7 +53,7 @@ const ActivatedUserCard = ({activatedUsers, seatsPurchased, isCloud}: ActivatedU /> {(text) => {text}} diff --git a/webapp/channels/src/components/analytics/system_analytics/system_analytics.tsx b/webapp/channels/src/components/analytics/system_analytics/system_analytics.tsx index 34a8da0f4a..458e670038 100644 --- a/webapp/channels/src/components/analytics/system_analytics/system_analytics.tsx +++ b/webapp/channels/src/components/analytics/system_analytics/system_analytics.tsx @@ -12,6 +12,7 @@ import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils'; import * as AdminActions from 'actions/admin_actions.jsx'; +import UserSeatAlertBanner from 'components/admin_console/license_settings/user_seat_alert_banner'; import ActivatedUserCard from 'components/analytics/activated_users_card'; import ExternalLink from 'components/external_link'; import AdminHeader from 'components/widgets/admin_console/admin_header'; @@ -510,6 +511,11 @@ export default class SystemAnalytics extends React.PureComponent {
+ {banner}
{systemCards} 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 735eca64b7..4c0b271ada 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 @@ -27,15 +27,10 @@ import './overage_users_banner.scss'; type AdminHasDismissedItArgs = { preferenceName: string; overagePreferences: PreferenceType[]; - isWarningBanner: boolean; } -const adminHasDismissed = ({preferenceName, overagePreferences, isWarningBanner}: AdminHasDismissedItArgs): boolean => { - if (isWarningBanner) { - return overagePreferences.find((value) => value.name === preferenceName) !== undefined; - } - - return false; +const adminHasDismissed = ({preferenceName, overagePreferences}: AdminHasDismissedItArgs): boolean => { + return overagePreferences.find((value) => value.name === preferenceName) !== undefined; }; const OverageUsersBanner = () => { @@ -62,7 +57,7 @@ const OverageUsersBanner = () => { const overageByUsers = activeUsers - seatsPurchased; - const isOverageState = isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats; + const isOverageState = overageByUsers > 0 && (isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats); const hasPermission = isAdmin && isOverageState && !isCloud; const { cta, @@ -89,14 +84,14 @@ const OverageUsersBanner = () => { const handleClick = handleContactSalesClick; - if (!hasPermission || adminHasDismissed({isWarningBanner: isBetween5PercerntAnd10PercentPurchasedSeats, overagePreferences, preferenceName})) { + if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) { return null; } const message = ( { return ( ({ const seatsPurchased = 40; const email = 'test@mattermost.com'; -const seatsMinimumFor5PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MIN)) + seatsPurchased; +const seatsMinimumFor5PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MIN)) + seatsPurchased + 1; -const seatsMinimumFor10PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MAX)) + seatsPurchased; +const seatsMinimumFor10PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MAX)) + seatsPurchased + 1; -const text5PercentageState = `(Only visible to admins) Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor5PercentageState - seatsPurchased} seats. Purchase additional seats to remain compliant.`; -const text10PercentageState = `(Only visible to admins) Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor10PercentageState - seatsPurchased} seats. Purchase additional seats to remain compliant.`; +const text5PercentageState = `(Only visible to admins) The user count exceeds the number of licensed seats by ${seatsMinimumFor5PercentageState - seatsPurchased} seat. Purchase more seats to stay compliant.`; +const text10PercentageState = `(Only visible to admins) The user count exceeds the number of licensed seats by ${seatsMinimumFor10PercentageState - seatsPurchased} seat. Purchase more seats to stay compliant.`; const contactSalesTextLink = 'Contact Sales'; @@ -127,7 +127,7 @@ describe('components/overage_users_banner', () => { it('should not render the banner because we are not on overage state', () => { renderWithContext(); - expect(screen.queryByText('(Only visible to admins) Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument(); + expect(screen.queryByText('(Only visible to admins) The user count exceeds the number of licensed seats by', {exact: false})).not.toBeInTheDocument(); }); it('should not render the banner because we are not admins', () => { @@ -146,7 +146,7 @@ describe('components/overage_users_banner', () => { renderWithContext(, store); - expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument(); + expect(screen.queryByText('Your workspace user count has exceeded your licensed seat count by', {exact: false})).not.toBeInTheDocument(); }); it('should not render the banner because it\'s cloud licenese', () => { @@ -159,7 +159,7 @@ describe('components/overage_users_banner', () => { renderWithContext(, store); - expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument(); + expect(screen.queryByText('Your workspace user count has exceeded your licensed seat count by', {exact: false})).not.toBeInTheDocument(); }); it('should not render the 5% banner because we have dissmised it', () => { @@ -170,7 +170,7 @@ describe('components/overage_users_banner', () => { { category: Preferences.OVERAGE_USERS_BANNER, value: 'Overage users banner watched', - name: `warn_overage_seats_${licenseId.substring(0, 8)}`, + name: `error_overage_seats_${licenseId.substring(0, 8)}`, }, ], ); @@ -230,7 +230,7 @@ describe('components/overage_users_banner', () => { const salesLinkWithEncodedParams = 'https://mattermost.com/contact-sales/?qk=&qp=&qw=&qx=dGVzdEBtYXR0ZXJtb3N0LmNvbQ==&utm_source=mattermost&utm_medium=in-product'; expect(windowSpy).toBeCalledWith(salesLinkWithEncodedParams, '_blank'); expect(trackEvent).toBeCalledTimes(1); - expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', { + expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', { cta: 'Contact Sales', banner: 'global banner', }); @@ -248,7 +248,7 @@ describe('components/overage_users_banner', () => { { category: Preferences.OVERAGE_USERS_BANNER, value: 'Overage users banner watched', - name: `warn_overage_seats_${10}`, + name: `error_overage_seats_${10}`, }, ], ); @@ -283,7 +283,7 @@ describe('components/overage_users_banner', () => { expect(savePreferences).toBeCalledTimes(1); expect(savePreferences).toBeCalledWith(store.entities.users.profiles.current_user.id, [{ category: Preferences.OVERAGE_USERS_BANNER, - name: `warn_overage_seats_${licenseId.substring(0, 8)}`, + name: `error_overage_seats_${licenseId.substring(0, 8)}`, user_id: store.entities.users.profiles.current_user.id, value: 'Overage users banner watched', }]); @@ -319,7 +319,7 @@ describe('components/overage_users_banner', () => { store.entities.admin = { ...store.entities.admin, analytics: { - [StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState, + [StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState + 1, }, }; 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 e070e65cfa..7d520d930f 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 @@ -58,7 +58,7 @@ const OverageUsersBannerNotice = () => { const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; const overageByUsers = activeUsers - seatsPurchased; - const isOverageState = isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats; + const isOverageState = overageByUsers > 0 && (isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats); const hasPermission = isAdmin && isOverageState && !isCloud; const { cta, @@ -120,7 +120,7 @@ const OverageUsersBannerNotice = () => { title={ ({ const seatsPurchased = 40; -const seatsMinimumFor5PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MIN)) + seatsPurchased; +const seatsMinimumFor5PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MIN)) + seatsPurchased + 1; -const seatsMinimumFor10PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MAX)) + seatsPurchased; +const seatsMinimumFor10PercentageState = (Math.ceil(seatsPurchased * OverActiveUserLimits.MAX)) + seatsPurchased + 1; -const text5PercentageState = `Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor5PercentageState - seatsPurchased} seats`; -const text10PercentageState = `Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor10PercentageState - seatsPurchased} seats`; +const text5PercentageState = `Your workspace user count has exceeded your licensed seat count by ${seatsMinimumFor5PercentageState - seatsPurchased} seat`; +const text10PercentageState = `Your workspace user count has exceeded your licensed seat count by ${seatsMinimumFor10PercentageState - seatsPurchased} seat`; const notifyText = 'Notify your Customer Success Manager on your next true-up check'; const contactSalesTextLink = 'Contact Sales'; @@ -169,7 +169,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => { { category: Preferences.OVERAGE_USERS_BANNER, value: 'Overage users banner watched', - name: `warn_overage_seats_${licenseId.substring(0, 8)}`, + name: `error_overage_seats_${licenseId.substring(0, 8)}`, }, ], ); @@ -234,7 +234,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => { '?utm_source=mattermost&utm_medium=in-product&utm_content=overage_users_banner&uid=current_user&sid=', ); expect(trackEvent).toBeCalledTimes(2); - expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', { + expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', { cta: 'Contact Sales', banner: 'invite modal', }); @@ -248,7 +248,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => { { category: Preferences.OVERAGE_USERS_BANNER, value: 'Overage users banner watched', - name: `warn_overage_seats_${generateId().substring(0, 8)}`, + name: `error_overage_seats_${generateId().substring(0, 8)}`, }, ], ); @@ -289,7 +289,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => { expect(savePreferences).toBeCalledTimes(1); expect(savePreferences).toBeCalledWith(store.entities.users.profiles.current_user.id, [{ category: Preferences.OVERAGE_USERS_BANNER, - name: `warn_overage_seats_${licenseId.substring(0, 8)}`, + name: `error_overage_seats_${licenseId.substring(0, 8)}`, user_id: store.entities.users.profiles.current_user.id, value: 'Overage users banner watched', }]); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index eaf03b180b..b2fff491da 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1565,6 +1565,14 @@ "admin.license.upload-modal.successfulUpgradeText": "You have upgraded to the {skuName} plan for {licensedUsersNum, number} seats. This is effective from {startsAt} until {expiresAt}. ", "admin.license.upload-modal.title": "Upload a License Key", "admin.license.uploadFile": "Upload File", + "admin.license.userSeatAlert.closeBtnTooltip": "Dismiss", + "admin.license.userSeatAlert.contactSales": "Contact Sales", + "admin.license.userSeatAlert.dangerMessage": "All licensed seats are now in use. Additional users may result in true-up charges at your next renewal. To maintain compliance and uninterrupted access, you may limit new sign-ups, or contact us to extend your license.", + "admin.license.userSeatAlert.dangerTitle": "Your workspace has reached 100% of your licensed seats", + "admin.license.userSeatAlert.infoMessage": "Your organization is approaching full license utilization. Now is a good time to assess future needs and align usage with procurement planning. Contact us to explore available options.", + "admin.license.userSeatAlert.infoTitle": "Your workspace has reached 95% of your licensed seats", + "admin.license.userSeatAlert.successMessage": "Congratulations! Platform adoption is strong across your organization. To ensure uninterrupted growth, our team can assist in scaling your license to meet operational requirements.", + "admin.license.userSeatAlert.successTitle": "Your workspace has reached 90% of your licensed seats", "admin.licenseSettings.teamEdition.teamEditionRightPanel.acceptTermsFinal": ". Upgrading will download the binary and update your team edition.", "admin.licenseSettings.teamEdition.teamEditionRightPanel.acceptTermsInitial": "By clicking Upgrade, I agree to the terms of the Mattermost ", "admin.licenseSettings.teamEdition.teamEditionRightPanel.eeLicenseLink": "Enterprise Edition License", @@ -3105,7 +3113,7 @@ "analytics.team.activeUsers": "Active Users With Posts", "analytics.team.newlyCreated": "Newly Created Users", "analytics.team.noTeams": "This server has no teams for which to view statistics.", - "analytics.team.overageUsersSeats": "This exceeds total paid seats", + "analytics.team.overageUsersSeats": "This exceeds total licensed seats", "analytics.team.privateGroups": "Private Channels", "analytics.team.publicChannels": "Public Channels", "analytics.team.recentUsers": "Recent Active Users", @@ -4462,8 +4470,8 @@ "licensingPage.infoBanner.startTrialTitle": "Start a free 30-day tral of Mattermost Enterprise Advanced", "licensingPage.overageUsersBanner.cta": "Contact Sales", "licensingPage.overageUsersBanner.noticeDescription": "Notify your Customer Success Manager on your next true-up check. ", - "licensingPage.overageUsersBanner.noticeTitle": "Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}", - "licensingPage.overageUsersBanner.text": "(Only visible to admins) Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}. Purchase additional seats to remain compliant.", + "licensingPage.overageUsersBanner.noticeTitle": "Your workspace user count has exceeded your licensed seat count by {seats, number} {seats, plural, one {seat} other {seats}}", + "licensingPage.overageUsersBanner.text": "(Only visible to admins) The user count exceeds the number of licensed seats by {seats, number} {seats, plural, one {seat} other {seats}}. Purchase more seats to stay compliant.", "link_preview.image_preview": "Show Image preview", "link_preview.remove_link_preview": "Remove link preview", "list_modal.paginatorCount": "{startCount, number} - {endCount, number} of {total, number} total", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 0fc2234733..c58982c9f2 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2250,9 +2250,12 @@ export const DataSearchTypes = { MESSAGES_SEARCH_TYPE: 'messages', } as const; +// Currently, the overage user limits are set to 0, which means that the overage active users warning +// is immediatly shown when over 100% usage. +// Originally it was set to Min 5% and Max 10% (5% would be a blue warning and 10% would be a red warning). export const OverActiveUserLimits = { - MIN: 0.05, - MAX: 0.1, + MIN: 0, + MAX: 0, } as const; export const PageLoadContext = {