Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Seats cap banners #23211

Merged
merged 8 commits into from
Sep 21, 2021
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