From 3818eeb3b17f7898e4566cda683a8ea165031855 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Tue, 21 Sep 2021 15:17:50 -0300 Subject: [PATCH] [NEW] Seats cap banners (#23211) * [NEW] Prevent users from accidentally deactivating an enterprise license by adding more users than the license allows. * Seats cap banners * Deprecate preserveDismiss * use request seats link * Fix banner not closing and request seats link Co-authored-by: Pierre Lehnen --- app/lib/server/functions/saveUser.js | 2 + .../server/functions/setUserActiveStatus.js | 8 ++ ee/app/license/server/getSeatsRequestLink.ts | 4 +- ee/app/license/server/license.ts | 37 ++++++ ee/app/license/server/maxSeatsBanners.ts | 106 ++++++++++++++++++ ee/server/startup/index.ts | 1 + ee/server/startup/seatsCap.ts | 76 +++++++++++++ packages/rocketchat-i18n/i18n/en.i18n.json | 2 + server/methods/deleteUser.js | 3 + server/sdk/types/IBannerService.ts | 4 +- server/services/banner/service.ts | 27 +++-- 11 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 ee/app/license/server/maxSeatsBanners.ts create mode 100644 ee/server/startup/index.ts create mode 100644 ee/server/startup/seatsCap.ts diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index 486d1ffecd5c..3fe5c67fb70d 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -364,6 +364,8 @@ export const saveUser = function(userId, userData) { Meteor.users.update({ _id: userData._id }, updateUser); + callbacks.run('afterSaveUser', userData); + if (sendPassword) { _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); } diff --git a/app/lib/server/functions/setUserActiveStatus.js b/app/lib/server/functions/setUserActiveStatus.js index 43bb0a5a46e3..63dd06be185d 100644 --- a/app/lib/server/functions/setUserActiveStatus.js +++ b/app/lib/server/functions/setUserActiveStatus.js @@ -62,6 +62,14 @@ export function setUserActiveStatus(userId, active, confirmRelinquish = false) { Users.setUserActive(userId, active); + if (active && !user.active) { + callbacks.run('afterActivateUser', user); + } + + if (!active && user.active) { + callbacks.run('afterDeactivateUser', user); + } + if (user.username) { Subscriptions.setArchivedByUsername(user.username, !active); } diff --git a/ee/app/license/server/getSeatsRequestLink.ts b/ee/app/license/server/getSeatsRequestLink.ts index f4e71dc52914..17c1160b4c51 100644 --- a/ee/app/license/server/getSeatsRequestLink.ts +++ b/ee/app/license/server/getSeatsRequestLink.ts @@ -11,11 +11,11 @@ export const getSeatsRequestLink = (): string => { const wizardSettings: WizardSettings = Settings.findSetupWizardSettings().fetch(); const utmUrl = new URL(url); - utmUrl.searchParams.append('workspaceId', String(workspaceId.value)); + utmUrl.searchParams.append('workspaceId', String(workspaceId?.value)); utmUrl.searchParams.append('activeUsers', String(activeUsers)); wizardSettings.forEach((setting) => { if (['Industry', 'Country', 'Size'].includes(setting._id)) { - utmUrl.searchParams.append(setting._id.toLowerCase(), String(setting.value)); + utmUrl.searchParams.append(setting._id.toLowerCase(), String(setting?.value)); } }); diff --git a/ee/app/license/server/license.ts b/ee/app/license/server/license.ts index ad0b962759f9..5495a284dc82 100644 --- a/ee/app/license/server/license.ts +++ b/ee/app/license/server/license.ts @@ -4,6 +4,12 @@ import { Users } from '../../../../app/models/server'; import { getBundleModules, isBundle, getBundleFromModule } from './bundles'; import decrypt from './decrypt'; import { getTagColor } from './getTagColor'; +import { + enableDangerBanner, + disableDangerBannerDiscardingDismissal, + enableWarningBanner, + disableWarningBannerDiscardingDismissal, +} from './maxSeatsBanners'; const EnterpriseLicenses = new EventEmitter(); @@ -333,6 +339,37 @@ export function flatModules(modulesAndBundles: string[]): string[] { return modules.concat(modulesFromBundles); } +export const handleMaxSeatsBanners = (): void => { + const maxActiveUsers = getMaxActiveUsers(); + + if (!maxActiveUsers) { + disableWarningBannerDiscardingDismissal(); + disableDangerBannerDiscardingDismissal(); + return; + } + + const activeUsers = Users.getActiveLocalUserCount(); + + // callback runs before user is added, so we should add the user + // that is being created to the current value. + const ratio = activeUsers / maxActiveUsers; + const seatsLeft = maxActiveUsers - activeUsers; + + if (ratio < 0.8 || ratio >= 1) { + disableWarningBannerDiscardingDismissal(); + } else { + enableWarningBanner(seatsLeft); + } + + if (ratio < 1) { + disableDangerBannerDiscardingDismissal(); + } else { + enableDangerBanner(); + } +}; + +onValidateLicenses(handleMaxSeatsBanners); + export interface IOverrideClassProperties { [key: string]: (...args: any[]) => any; } diff --git a/ee/app/license/server/maxSeatsBanners.ts b/ee/app/license/server/maxSeatsBanners.ts new file mode 100644 index 000000000000..8d2de5dd839b --- /dev/null +++ b/ee/app/license/server/maxSeatsBanners.ts @@ -0,0 +1,106 @@ +import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; +import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +import { IBanner, BannerPlatform } from '../../../../definition/IBanner'; +import { Banner } from '../../../../server/sdk'; +import { getSeatsRequestLink } from './getSeatsRequestLink'; + +const WARNING_BANNER_ID = 'closeToSeatsLimit'; +const DANGER_BANNER_ID = 'reachedSeatsLimit'; + + +const makeWarningBanner = (seats: number): IBanner => ({ + _id: WARNING_BANNER_ID, + platform: [BannerPlatform.Web], + roles: ['admin'], + view: { + icon: 'warning', + variant: 'warning', + viewId: '', + appId: 'banner-core', + blocks: [{ + type: BlockType.SECTION, + blockId: 'attention', + text: { + type: TextObjectType.MARKDOWN, + text: TAPi18n.__('Close_to_seat_limit_banner_warning', { seats, url: getSeatsRequestLink() }), + emoji: false, + }, + }], + }, + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + expireAt: new Date(8640000000000000), + startAt: new Date(), + createdAt: new Date(), + _updatedAt: new Date(), + active: false, +}); + +const makeDangerBanner = (): IBanner => ({ + _id: DANGER_BANNER_ID, + platform: [BannerPlatform.Web], + roles: ['admin'], + view: { + icon: 'ban', + variant: 'danger', + viewId: '', + appId: 'banner-core', + blocks: [{ + type: BlockType.SECTION, + blockId: 'attention', + text: { + type: TextObjectType.MARKDOWN, + text: TAPi18n.__('Reached_seat_limit_banner_warning', { url: getSeatsRequestLink() }), + emoji: false, + }, + }], + }, + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + expireAt: new Date(8640000000000000), + startAt: new Date(), + createdAt: new Date(), + _updatedAt: new Date(), + active: false, +}); + +export const createSeatsLimitBanners = async (): Promise => { + const [warning, danger] = await Promise.all([Banner.getById(WARNING_BANNER_ID), Banner.getById(DANGER_BANNER_ID)]); + if (!warning) { + Banner.create(makeWarningBanner(0)); + } + if (!danger) { + Banner.create(makeDangerBanner()); + } +}; + +export const enableDangerBanner = (): void => { + Banner.enable(DANGER_BANNER_ID, makeDangerBanner()); +}; + + +export const disableDangerBannerDiscardingDismissal = async (): Promise => { + const banner = await Banner.getById(DANGER_BANNER_ID); + if (banner && banner.active) { + Banner.disable(DANGER_BANNER_ID); + Banner.discardDismissal(DANGER_BANNER_ID); + } +}; + +export const enableWarningBanner = (seatsLeft: number): void => { + Banner.enable(WARNING_BANNER_ID, makeWarningBanner(seatsLeft)); +}; + +export const disableWarningBannerDiscardingDismissal = async (): Promise => { + const banner = await Banner.getById(WARNING_BANNER_ID); + if (banner && banner.active) { + Banner.disable(WARNING_BANNER_ID); + Banner.discardDismissal(WARNING_BANNER_ID); + } +}; diff --git a/ee/server/startup/index.ts b/ee/server/startup/index.ts new file mode 100644 index 000000000000..2a58ffbd4b4b --- /dev/null +++ b/ee/server/startup/index.ts @@ -0,0 +1 @@ +import './seatsCap'; diff --git a/ee/server/startup/seatsCap.ts b/ee/server/startup/seatsCap.ts new file mode 100644 index 000000000000..800d4f783ffc --- /dev/null +++ b/ee/server/startup/seatsCap.ts @@ -0,0 +1,76 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +import { callbacks } from '../../../app/callbacks/server'; +import { canAddNewUser, handleMaxSeatsBanners, isEnterprise } from '../../app/license/server/license'; +import { createSeatsLimitBanners } from '../../app/license/server/maxSeatsBanners'; +import { validateUserRoles } from '../../app/authorization/server/validateUserRoles'; +import { Users } from '../../../app/models/server'; +import type { IUser } from '../../../definition/IUser'; + +callbacks.add('onCreateUser', ({ isGuest }: { isGuest: boolean }) => { + if (isGuest) { + return; + } + + if (!canAddNewUser()) { + throw new Meteor.Error('error-license-user-limit-reached', TAPi18n.__('error-license-user-limit-reached')); + } +}, callbacks.priority.MEDIUM, 'check-max-user-seats'); + + +callbacks.add('beforeActivateUser', (user: IUser) => { + if (user.roles.length === 1 && user.roles.includes('guest')) { + return; + } + + if (user.type === 'app') { + return; + } + + if (!canAddNewUser()) { + throw new Meteor.Error('error-license-user-limit-reached', TAPi18n.__('error-license-user-limit-reached')); + } +}, callbacks.priority.MEDIUM, 'check-max-user-seats'); + +callbacks.add('validateUserRoles', (userData: Record) => { + const isGuest = userData.roles?.includes('guest'); + if (isGuest) { + validateUserRoles(Meteor.userId(), userData); + return; + } + + if (!userData._id) { + return; + } + + const currentUserData = Users.findOneById(userData._id); + if (currentUserData.type === 'app') { + return; + } + + const wasGuest = currentUserData?.roles?.length === 1 && currentUserData.roles.includes('guest'); + if (!wasGuest) { + return; + } + + if (!canAddNewUser()) { + throw new Meteor.Error('error-license-user-limit-reached', TAPi18n.__('error-license-user-limit-reached')); + } +}, callbacks.priority.MEDIUM, 'check-max-user-seats'); + +callbacks.add('afterCreateUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners'); + +callbacks.add('afterSaveUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners'); + +callbacks.add('afterDeleteUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners'); + +callbacks.add('afterDeactivateUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners'); + +callbacks.add('afterActivateUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners'); + +Meteor.startup(() => { + if (isEnterprise()) { + createSeatsLimitBanners(); + } +}); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 6e5a5374ed01..50151dfd8c1c 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -868,6 +868,7 @@ "Close": "Close", "Close_chat": "Close chat", "Close_room_description": "You are about to close this chat. Are you sure you want to continue?", + "Close_to_seat_limit_banner_warning": "*You have [__seats__] seats left*\nThis workspace is nearing its seat limit. Once the limit is met no new members can be added. *[Request More Seats](__url__)*", "Close_to_seat_limit_warning": "New members cannot be created once the seat limit is met.", "close-livechat-room": "Close Omnichannel Room", "close-livechat-room_description": "Permission to close the current Omnichannel room", @@ -3361,6 +3362,7 @@ "Random": "Random", "RD Station": "RD Station", "RDStation_Token": "RD Station Token", + "Reached_seat_limit_banner_warning": "*No more seats available*\nThis workspace has reached its seat limit so no more members can join. *[Request More Seats](__url__)*", "React_when_read_only": "Allow Reacting", "React_when_read_only_changed_successfully": "Allow reacting when read only changed successfully", "Reacted_with": "Reacted with", diff --git a/server/methods/deleteUser.js b/server/methods/deleteUser.js index e2c169167ff7..f47da58045e4 100644 --- a/server/methods/deleteUser.js +++ b/server/methods/deleteUser.js @@ -3,6 +3,7 @@ import { check } from 'meteor/check'; import { Users } from '../../app/models'; import { hasPermission } from '../../app/authorization'; +import { callbacks } from '../../app/callbacks/server'; import { deleteUser } from '../../app/lib/server'; Meteor.methods({ @@ -47,6 +48,8 @@ Meteor.methods({ deleteUser(userId, confirmRelinquish); + callbacks.run('afterDeleteUser', user); + return true; }, }); diff --git a/server/sdk/types/IBannerService.ts b/server/sdk/types/IBannerService.ts index 01717a1408a0..2779f6fbfbee 100644 --- a/server/sdk/types/IBannerService.ts +++ b/server/sdk/types/IBannerService.ts @@ -5,6 +5,8 @@ export interface IBannerService { getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise; create(banner: Optional): Promise; dismiss(userId: string, bannerId: string): Promise; + discardDismissal(bannerId: string): Promise; + getById(bannerId: string): Promise; disable(bannerId: string): Promise; - enable(bannerId: string, doc?: Partial>, keepDismiss?: boolean): Promise; + enable(bannerId: string, doc?: Partial>): Promise; } diff --git a/server/services/banner/service.ts b/server/services/banner/service.ts index 3b2492ffa0a8..bcd3de4b9e34 100644 --- a/server/services/banner/service.ts +++ b/server/services/banner/service.ts @@ -28,6 +28,25 @@ export class BannerService extends ServiceClass implements IBannerService { this.Users = new UsersRaw(db.collection('users')); } + async getById(bannerId: string): Promise { + return this.Banners.findOneById(bannerId); + } + + async discardDismissal(bannerId: string): Promise { + const result = await this.Banners.findOneById(bannerId); + + if (!result) { + return false; + } + + const { _id, ...banner } = result; + + const snapshot = await this.create({ ...banner, snapshot: _id, active: false }); // create a snapshot + + await this.BannersDismiss.updateMany({ bannerId }, { $set: { bannerId: snapshot._id } }); + return true; + } + async create(doc: Optional): Promise { const bannerId = doc._id || uuidv4(); @@ -110,7 +129,7 @@ export class BannerService extends ServiceClass implements IBannerService { return false; } - async enable(bannerId: string, doc: Partial> = {}, preserveDismiss = false): Promise { + async enable(bannerId: string, doc: Partial> = {}): Promise { const result = await this.Banners.findOneById(bannerId); if (!result) { @@ -119,12 +138,6 @@ export class BannerService extends ServiceClass implements IBannerService { const { _id, ...banner } = result; - const snapshot = await this.create({ ...banner, snapshot: _id }); // create a snapshot - - if (!preserveDismiss) { - await this.BannersDismiss.updateMany({ bannerId }, { $set: { bannerId: snapshot._id } }); - } - this.Banners.update({ _id }, { ...banner, ...doc, active: true }); // reenable the banner api.broadcast('banner.enabled', bannerId);