Skip to content

Commit

Permalink
[NEW] Seats cap banners (#23211)
Browse files Browse the repository at this point in the history
* [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 <pierre.lehnen@rocket.chat>
  • Loading branch information
gabriellsh and pierre-lehnen-rc authored Sep 21, 2021
1 parent e530fae commit 3818eeb
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 10 deletions.
2 changes: 2 additions & 0 deletions app/lib/server/functions/saveUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions app/lib/server/functions/setUserActiveStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions ee/app/license/server/getSeatsRequestLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
});

Expand Down
37 changes: 37 additions & 0 deletions ee/app/license/server/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
}
Expand Down
106 changes: 106 additions & 0 deletions ee/app/license/server/maxSeatsBanners.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
const banner = await Banner.getById(WARNING_BANNER_ID);
if (banner && banner.active) {
Banner.disable(WARNING_BANNER_ID);
Banner.discardDismissal(WARNING_BANNER_ID);
}
};
1 change: 1 addition & 0 deletions ee/server/startup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './seatsCap';
76 changes: 76 additions & 0 deletions ee/server/startup/seatsCap.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
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();
}
});
2 changes: 2 additions & 0 deletions packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions server/methods/deleteUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -47,6 +48,8 @@ Meteor.methods({

deleteUser(userId, confirmRelinquish);

callbacks.run('afterDeleteUser', user);

return true;
},
});
4 changes: 3 additions & 1 deletion server/sdk/types/IBannerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface IBannerService {
getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]>;
create(banner: Optional<IBanner, '_id'>): Promise<IBanner>;
dismiss(userId: string, bannerId: string): Promise<boolean>;
discardDismissal(bannerId: string): Promise<boolean>;
getById(bannerId: string): Promise<null | IBanner>;
disable(bannerId: string): Promise<boolean>;
enable(bannerId: string, doc?: Partial<Omit<IBanner, '_id'>>, keepDismiss?: boolean): Promise<boolean>;
enable(bannerId: string, doc?: Partial<Omit<IBanner, '_id'>>): Promise<boolean>;
}
27 changes: 20 additions & 7 deletions server/services/banner/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ export class BannerService extends ServiceClass implements IBannerService {
this.Users = new UsersRaw(db.collection('users'));
}

async getById(bannerId: string): Promise<null | IBanner> {
return this.Banners.findOneById(bannerId);
}

async discardDismissal(bannerId: string): Promise<boolean> {
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<IBanner, '_id'>): Promise<IBanner> {
const bannerId = doc._id || uuidv4();

Expand Down Expand Up @@ -110,7 +129,7 @@ export class BannerService extends ServiceClass implements IBannerService {
return false;
}

async enable(bannerId: string, doc: Partial<Omit<IBanner, '_id'>> = {}, preserveDismiss = false): Promise<boolean> {
async enable(bannerId: string, doc: Partial<Omit<IBanner, '_id'>> = {}): Promise<boolean> {
const result = await this.Banners.findOneById(bannerId);

if (!result) {
Expand All @@ -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);
Expand Down

0 comments on commit 3818eeb

Please sign in to comment.