diff --git a/app/api/server/v1/banners.ts b/app/api/server/v1/banners.ts index 07a8077d724..515527273ee 100644 --- a/app/api/server/v1/banners.ts +++ b/app/api/server/v1/banners.ts @@ -1,12 +1,13 @@ import { Promise } from 'meteor/promise'; import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; +import { TextObjectType, BlockType } from '@rocket.chat/apps-engine/definition/uikit'; import { API } from '../api'; import { Banner } from '../../../../server/sdk'; -import { BannerPlatform } from '../../../../definition/IBanner'; +import { BannerPlatform, IBanner } from '../../../../definition/IBanner'; -API.v1.addRoute('banners.getNew', { authRequired: true }, { +API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated get() { check(this.queryParams, Match.ObjectIncluding({ platform: String, @@ -22,12 +23,129 @@ API.v1.addRoute('banners.getNew', { authRequired: true }, { throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); } - const banners = Promise.await(Banner.getNewBannersForUser(this.userId, platform, bannerId)); + const banners = Promise.await(Banner.getBannersForUser(this.userId, platform, bannerId)); return API.v1.success({ banners }); }, }); + +API.v1.addRoute('banners/:id', { authRequired: true }, { + + get() { + check(this.urlParams, Match.ObjectIncluding({ + id: String, + })); + + const { platform } = this.queryParams; + if (!platform) { + throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); + } + + const { id } = this.urlParams; + if (!id) { + throw new Meteor.Error('error-missing-param', 'The required "id" param is missing.'); + } + + const banners = Promise.await(Banner.getBannersForUser(this.userId, platform, id)); + + return API.v1.success({ banners }); + }, +}); +API.v1.addRoute('banners', { authRequired: true }, { + + get() { + check(this.queryParams, Match.ObjectIncluding({ + platform: String, + })); + + const { platform } = this.queryParams; + if (!platform) { + throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); + } + + if (!Object.values(BannerPlatform).includes(platform)) { + throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); + } + + const banners = Promise.await(Banner.getBannersForUser(this.userId, platform)); + + return API.v1.success({ banners }); + }, + ...process.env.NODE_ENV !== 'production' && { + post(): {} { + check(this.bodyParams, Match.ObjectIncluding({ + platform: Match.Maybe(String), + bid: String, + })); + + const { platform = 'web', bid: bannerId } = this.bodyParams; + + if (!platform) { + throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); + } + + if (!Object.values(BannerPlatform).includes(platform)) { + throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); + } + const b: IBanner = { + _id: bannerId, + platform: [platform], + expireAt: new Date(new Date().getTime() + (1000 * 60 * 60 * 24 * 7)), + startAt: new Date(), + roles: ['admin'], + createdBy: { + _id: this.userId, + username: this.userId, + }, + createdAt: new Date(), + _updatedAt: new Date(), + view: { + viewId: '', + appId: '', + blocks: [{ + type: BlockType.SECTION, + blockId: 'attention', + text: { + type: TextObjectType.PLAINTEXT, + text: 'Test', + emoji: false, + }, + }], + }, + }; + + const banners = Promise.await(Banner.create(b)); + + return API.v1.success({ banners }); + }, + delete(): {} { + check(this.bodyParams, Match.ObjectIncluding({ + bid: String, + })); + + const { bid } = this.bodyParams; + + Promise.await(Banner.disable(bid)); + + return API.v1.success(); + }, + + patch(): {} { + check(this.bodyParams, Match.ObjectIncluding({ + bid: String, + })); + + const { bid } = this.bodyParams; + + Promise.await(Banner.enable(bid)); + + return API.v1.success(); + }, + }, +}); + + API.v1.addRoute('banners.dismiss', { authRequired: true }, { post() { check(this.bodyParams, Match.ObjectIncluding({ diff --git a/app/authentication/server/startup/index.js b/app/authentication/server/startup/index.js index 680041fd2d5..3bf9e82da3c 100644 --- a/app/authentication/server/startup/index.js +++ b/app/authentication/server/startup/index.js @@ -206,6 +206,7 @@ Accounts.onCreateUser(function(options, user = {}) { Mailer.send(email); } + callbacks.run('onCreateUser', options, user); return user; }); diff --git a/app/crowd/server/crowd.js b/app/crowd/server/crowd.js index f2dd3acbaee..c21b4550bd9 100644 --- a/app/crowd/server/crowd.js +++ b/app/crowd/server/crowd.js @@ -10,6 +10,7 @@ import { Users } from '../../models'; import { settings } from '../../settings'; import { hasRole } from '../../authorization'; import { deleteUser } from '../../lib/server/functions'; +import { setUserActiveStatus } from '../../lib/server/functions/setUserActiveStatus'; const logger = new Logger('CROWD'); @@ -154,7 +155,6 @@ export class CROWD { address: crowdUser.email, verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), }], - active: crowdUser.active, crowd: true, }; @@ -173,6 +173,8 @@ export class CROWD { Meteor.users.update(id, { $set: user, }); + + setUserActiveStatus(id, crowdUser.active); } sync() { diff --git a/app/dolphin/lib/common.js b/app/dolphin/lib/common.js index 0e74e6d1fca..051a1a339a4 100644 --- a/app/dolphin/lib/common.js +++ b/app/dolphin/lib/common.js @@ -25,7 +25,7 @@ function DolphinOnCreateUser(options, user) { if (user && user.services && user.services.dolphin && user.services.dolphin.NickName) { user.username = user.services.dolphin.NickName; } - return user; + return options; } if (Meteor.isServer) { diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index a5989de04a1..3fe5c67fb70 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -9,10 +9,10 @@ import { getRoles, hasPermission } from '../../../authorization'; import { settings } from '../../../settings'; import { passwordPolicy } from '../lib/passwordPolicy'; import { validateEmailDomain } from '../lib'; -import { validateUserRoles } from '../../../../ee/app/authorization/server/validateUserRoles'; import { getNewUserRoles } from '../../../../server/services/user/lib/getNewUserRoles'; import { saveUserIdentity } from './saveUserIdentity'; import { checkEmailAvailability, checkUsernameAvailability, setUserAvatar, setEmail, setStatusText } from '.'; +import { callbacks } from '../../../callbacks/server'; let html = ''; let passwordChangedHtml = ''; @@ -98,7 +98,7 @@ function validateUserData(userId, userData) { } if (userData.roles) { - validateUserRoles(userId, userData); + callbacks.run('validateUserRoles', userData); } let nameValidation; @@ -227,77 +227,85 @@ const handleNickname = (updateUser, nickname) => { } }; -export const saveUser = function(userId, userData) { - validateUserData(userId, userData); - let sendPassword = false; +const saveNewUser = function(userData, sendPassword) { + validateEmailDomain(userData.email); - if (userData.hasOwnProperty('setRandomPassword')) { - if (userData.setRandomPassword) { - userData.password = passwordPolicy.generatePassword(); - userData.requirePasswordChange = true; - sendPassword = true; - } + const roles = userData.roles || getNewUserRoles(); + const isGuest = roles && roles.length === 1 && roles.includes('guest'); - delete userData.setRandomPassword; + // insert user + const createUser = { + username: userData.username, + password: userData.password, + joinDefaultChannels: userData.joinDefaultChannels, + isGuest, + }; + if (userData.email) { + createUser.email = userData.email; } - if (!userData._id) { - validateEmailDomain(userData.email); + const _id = Accounts.createUser(createUser); - // insert user - const createUser = { - username: userData.username, - password: userData.password, - joinDefaultChannels: userData.joinDefaultChannels, - }; - if (userData.email) { - createUser.email = userData.email; - } + const updateUser = { + $set: { + roles, + ...typeof userData.name !== 'undefined' && { name: userData.name }, + settings: userData.settings || {}, + }, + }; - const _id = Accounts.createUser(createUser); + if (typeof userData.requirePasswordChange !== 'undefined') { + updateUser.$set.requirePasswordChange = userData.requirePasswordChange; + } + + if (typeof userData.verified === 'boolean') { + updateUser.$set['emails.0.verified'] = userData.verified; + } - const updateUser = { - $set: { - roles: userData.roles || getNewUserRoles(), - ...typeof userData.name !== 'undefined' && { name: userData.name }, - settings: userData.settings || {}, - }, - }; + handleBio(updateUser, userData.bio); + handleNickname(updateUser, userData.nickname); - if (typeof userData.requirePasswordChange !== 'undefined') { - updateUser.$set.requirePasswordChange = userData.requirePasswordChange; - } + Meteor.users.update({ _id }, updateUser); - if (typeof userData.verified === 'boolean') { - updateUser.$set['emails.0.verified'] = userData.verified; - } + if (userData.sendWelcomeEmail) { + _sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); + } - handleBio(updateUser, userData.bio); - handleNickname(updateUser, userData.nickname); + if (sendPassword) { + _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); + } - Meteor.users.update({ _id }, updateUser); + userData._id = _id; - if (userData.sendWelcomeEmail) { - _sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); - } + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { + const gravatarUrl = Gravatar.imageUrl(userData.email, { default: '404', size: 200, secure: true }); - if (sendPassword) { - _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); + try { + setUserAvatar(userData, gravatarUrl, '', 'url'); + } catch (e) { + // Ignore this error for now, as it not being successful isn't bad } + } - userData._id = _id; + return _id; +}; - if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { - const gravatarUrl = Gravatar.imageUrl(userData.email, { default: '404', size: 200, secure: true }); +export const saveUser = function(userId, userData) { + validateUserData(userId, userData); + let sendPassword = false; - try { - setUserAvatar(userData, gravatarUrl, '', 'url'); - } catch (e) { - // Ignore this error for now, as it not being successful isn't bad - } + if (userData.hasOwnProperty('setRandomPassword')) { + if (userData.setRandomPassword) { + userData.password = passwordPolicy.generatePassword(); + userData.requirePasswordChange = true; + sendPassword = true; } - return _id; + delete userData.setRandomPassword; + } + + if (!userData._id) { + return saveNewUser(userData, sendPassword); } validateUserEditing(userId, userData); @@ -356,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 28d3111d0d2..63dd06be185 100644 --- a/app/lib/server/functions/setUserActiveStatus.js +++ b/app/lib/server/functions/setUserActiveStatus.js @@ -5,6 +5,7 @@ import { Accounts } from 'meteor/accounts-base'; import * as Mailer from '../../../mailer'; import { Users, Subscriptions, Rooms } from '../../../models'; import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks/server'; import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; import { closeOmnichannelConversations } from './closeOmnichannelConversations'; import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; @@ -55,8 +56,20 @@ export function setUserActiveStatus(userId, active, confirmRelinquish = false) { relinquishRoomOwnerships(user, chatSubscribedRooms, false); } + if (active && !user.active) { + callbacks.run('beforeActivateUser', user); + } + 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/app/meteor-accounts-saml/server/loginHandler.ts b/app/meteor-accounts-saml/server/loginHandler.ts index 64680efbfb6..0a6f3b61a7b 100644 --- a/app/meteor-accounts-saml/server/loginHandler.ts +++ b/app/meteor-accounts-saml/server/loginHandler.ts @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { SAMLUtils } from './lib/Utils'; import { SAML } from './lib/SAML'; @@ -31,8 +32,25 @@ Accounts.registerLoginHandler('saml', function(loginRequest) { const userObject = SAMLUtils.mapProfileToUserObject(loginResult.profile); return SAML.insertOrUpdateSAMLUser(userObject); - } catch (error) { + } catch (error: any) { SystemLogger.error(error); - return makeError(error.toString()); + + let message = error.toString(); + let errorCode = ''; + + if (error instanceof Meteor.Error) { + errorCode = (error.error || error.message) as string; + } else if (error instanceof Error) { + errorCode = error.message; + } + + if (errorCode) { + const localizedMessage = TAPi18n.__(errorCode); + if (localizedMessage && localizedMessage !== errorCode) { + message = localizedMessage; + } + } + + return makeError(message); } }); diff --git a/app/models/server/raw/Banners.ts b/app/models/server/raw/Banners.ts index 3a6a596d272..301ea579bc0 100644 --- a/app/models/server/raw/Banners.ts +++ b/app/models/server/raw/Banners.ts @@ -1,4 +1,4 @@ -import { Collection, Cursor, FindOneOptions, WithoutProjection } from 'mongodb'; +import { Collection, Cursor, FindOneOptions, UpdateWriteOpResult, WithoutProjection, InsertOneWriteOpResult } from 'mongodb'; import { BannerPlatform, IBanner } from '../../../../definition/IBanner'; import { BaseRaw } from './BaseRaw'; @@ -14,6 +14,30 @@ export class BannersRaw extends BaseRaw { this.col.createIndexes([ { key: { platform: 1, startAt: 1, expireAt: 1 } }, ]); + + this.col.createIndexes([ + { key: { platform: 1, startAt: 1, expireAt: 1, active: 1 } }, + ]); + } + + create(doc: IBanner): Promise> { + const invalidPlatform = doc.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform)); + if (invalidPlatform) { + throw new Error('Invalid platform'); + } + + if (doc.startAt > doc.expireAt) { + throw new Error('Start date cannot be later than expire date'); + } + + if (doc.expireAt < new Date()) { + throw new Error('Cannot create banner already expired'); + } + + return this.insertOne({ + active: true, + ...doc, + }); } findActiveByRoleOrId(roles: string[], platform: BannerPlatform, bannerId?: string, options?: WithoutProjection>): Cursor { @@ -24,6 +48,7 @@ export class BannersRaw extends BaseRaw { platform, startAt: { $lte: today }, expireAt: { $gte: today }, + active: { $ne: false }, $or: [ { roles: { $in: roles } }, { roles: { $exists: false } }, @@ -32,4 +57,8 @@ export class BannersRaw extends BaseRaw { return this.col.find(query, options); } + + disable(bannerId: string): Promise { + return this.col.updateOne({ _id: bannerId, active: { $ne: false } }, { $set: { active: false, inactivedAt: new Date() } }); + } } diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index cf6390b2b00..537b164552c 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -25,7 +25,7 @@ import { readSecondaryPreferred } from '../../../../server/database/readSecondar import { getAppsStatistics } from './getAppsStatistics'; import { getServicesStatistics } from './getServicesStatistics'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; -import { Team } from '../../../../server/sdk'; +import { Team, Analytics } from '../../../../server/sdk'; const wizardFields = [ 'Organization_Type', @@ -211,6 +211,7 @@ export const statistics = { statistics.pushQueue = Promise.await(NotificationQueue.col.estimatedDocumentCount()); statistics.enterprise = getEnterpriseStatistics(); + Promise.await(Analytics.resetSeatRequestCount()); return statistics; }, diff --git a/app/ui-login/client/login/form.js b/app/ui-login/client/login/form.js index dd9eda6c08b..601ad2ff182 100644 --- a/app/ui-login/client/login/form.js +++ b/app/ui-login/client/login/form.js @@ -141,21 +141,26 @@ Template.loginForm.events({ return Meteor[loginMethod](s.trim(formData.emailOrUsername), formData.pass, function(error) { instance.loading.set(false); if (error != null) { - if (error.error === 'error-user-is-not-activated') { - return toastr.error(t('Wait_activation_warning')); - } if (error.error === 'error-invalid-email') { - instance.typedEmail = formData.emailOrUsername; - return instance.state.set('email-verification'); - } if (error.error === 'error-user-is-not-activated') { - toastr.error(t('Wait_activation_warning')); - } else if (error.error === 'error-app-user-is-not-allowed-to-login') { - toastr.error(t('App_user_not_allowed_to_login')); - } else if (error.error === 'error-login-blocked-for-ip') { - toastr.error(t('Error_login_blocked_for_ip')); - } else if (error.error === 'error-login-blocked-for-user') { - toastr.error(t('Error_login_blocked_for_user')); - } else { - return toastr.error(t('User_not_found_or_incorrect_password')); + switch (error.error) { + case 'error-user-is-not-activated': + return toastr.error(t('Wait_activation_warning')); + case 'error-invalid-email': + instance.typedEmail = formData.emailOrUsername; + return instance.state.set('email-verification'); + case 'error-app-user-is-not-allowed-to-login': + toastr.error(t('App_user_not_allowed_to_login')); + break; + case 'error-login-blocked-for-ip': + toastr.error(t('Error_login_blocked_for_ip')); + break; + case 'error-login-blocked-for-user': + toastr.error(t('Error_login_blocked_for_user')); + break; + case 'error-license-user-limit-reached': + toastr.error(t('error-license-user-limit-reached')); + break; + default: + return toastr.error(t('User_not_found_or_incorrect_password')); } } Session.set('forceLogin', false); diff --git a/client/components/Card/CardIcon.tsx b/client/components/Card/CardIcon.tsx index 66e387834c8..6a240af1a8f 100644 --- a/client/components/Card/CardIcon.tsx +++ b/client/components/Card/CardIcon.tsx @@ -1,7 +1,12 @@ import { Box, Icon } from '@rocket.chat/fuselage'; -import React, { FC } from 'react'; +import React, { ComponentProps, ReactElement, ReactNode } from 'react'; -const CardIcon: FC<{ name: string }> = ({ name, children, ...props }) => ( +type CardIconProps = { children: ReactNode } | ComponentProps; + +const hasChildrenProp = (props: CardIconProps): props is { children: ReactNode } => + 'children' in props; + +const CardIcon = (props: CardIconProps): ReactElement => ( = ({ name, children, ...props }) => ( alignItems='flex-end' justifyContent='center' > - {children || } + {hasChildrenProp(props) ? props.children : } ); diff --git a/client/components/UserStatus/UserStatus.js b/client/components/UserStatus/UserStatus.tsx similarity index 75% rename from client/components/UserStatus/UserStatus.js rename to client/components/UserStatus/UserStatus.tsx index e0c93b1d116..abb04963347 100644 --- a/client/components/UserStatus/UserStatus.js +++ b/client/components/UserStatus/UserStatus.tsx @@ -1,9 +1,13 @@ import { StatusBullet } from '@rocket.chat/fuselage'; -import React, { memo } from 'react'; +import React, { memo, ComponentProps, ReactElement } from 'react'; import { useTranslation } from '../../contexts/TranslationContext'; -const UserStatus = ({ small, status, ...props }) => { +type UserStatusProps = { + small?: boolean; +} & ComponentProps; + +const UserStatus = ({ small, status, ...props }: UserStatusProps): ReactElement => { const size = small ? 'small' : 'large'; const t = useTranslation(); switch (status) { diff --git a/client/contexts/ServerContext/ServerContext.ts b/client/contexts/ServerContext/ServerContext.ts index 6e38e849f33..d791fea0d2d 100644 --- a/client/contexts/ServerContext/ServerContext.ts +++ b/client/contexts/ServerContext/ServerContext.ts @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; +import { IServerInfo } from '../../../definition/IServerInfo'; import type { Serialized } from '../../../definition/Serialized'; import type { PathFor, Params, Return, Method } from './endpoints'; import { @@ -11,7 +12,7 @@ import { } from './methods'; type ServerContextValue = { - info: {}; + info?: IServerInfo; absoluteUrl: (path: string) => string; callMethod?: ( methodName: MethodName, @@ -30,7 +31,7 @@ type ServerContextValue = { }; export const ServerContext = createContext({ - info: {}, + info: undefined, absoluteUrl: (path) => path, callEndpoint: () => { throw new Error('not implemented'); @@ -39,7 +40,13 @@ export const ServerContext = createContext({ getStream: () => () => (): void => undefined, }); -export const useServerInformation = (): {} => useContext(ServerContext).info; +export const useServerInformation = (): IServerInfo => { + const { info } = useContext(ServerContext); + if (!info) { + throw new Error('useServerInformation: no info available'); + } + return info; +}; export const useAbsoluteUrl = (): ((path: string) => string) => useContext(ServerContext).absoluteUrl; diff --git a/client/contexts/ServerContext/endpoints.ts b/client/contexts/ServerContext/endpoints.ts index 96d5cc12ce9..0a57ef4479d 100644 --- a/client/contexts/ServerContext/endpoints.ts +++ b/client/contexts/ServerContext/endpoints.ts @@ -11,9 +11,11 @@ import type { EmojiCustomEndpoints } from './endpoints/v1/emojiCustom'; import type { GroupsEndpoints } from './endpoints/v1/groups'; import type { ImEndpoints } from './endpoints/v1/im'; import type { LDAPEndpoints } from './endpoints/v1/ldap'; +import type { LicensesEndpoints } from './endpoints/v1/licenses'; import type { MiscEndpoints } from './endpoints/v1/misc'; import type { OmnichannelEndpoints } from './endpoints/v1/omnichannel'; import type { RoomsEndpoints } from './endpoints/v1/rooms'; +import type { StatisticsEndpoints } from './endpoints/v1/statistics'; import type { TeamsEndpoints } from './endpoints/v1/teams'; import type { UsersEndpoints } from './endpoints/v1/users'; @@ -33,6 +35,8 @@ type Endpoints = ChatEndpoints & EngagementDashboardEndpoints & AppsEndpoints & OmnichannelEndpoints & + StatisticsEndpoints & + LicensesEndpoints & MiscEndpoints; type Endpoint = UnionizeEndpoints; diff --git a/client/contexts/ServerContext/endpoints/v1/licenses.ts b/client/contexts/ServerContext/endpoints/v1/licenses.ts new file mode 100644 index 00000000000..5d78d69dd5e --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/licenses.ts @@ -0,0 +1,13 @@ +import type { ILicense } from '../../../../../ee/app/license/server/license'; + +export type LicensesEndpoints = { + 'licenses.get': { + GET: () => { licenses: Array }; + }; + 'licenses.maxActiveUsers': { + GET: () => { maxActiveUsers: number | null; activeUsers: number }; + }; + 'licenses.requestSeatsLink': { + GET: () => { url: string }; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/statistics.ts b/client/contexts/ServerContext/endpoints/v1/statistics.ts new file mode 100644 index 00000000000..178d8f5d66b --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/statistics.ts @@ -0,0 +1,7 @@ +import type { IStats } from '../../../../../definition/IStats'; + +export type StatisticsEndpoints = { + statistics: { + GET: (params: { refresh?: boolean }) => IStats; + }; +}; diff --git a/client/hooks/useLocalePercentage.ts b/client/hooks/useLocalePercentage.ts new file mode 100644 index 00000000000..f8d4cbf4dc3 --- /dev/null +++ b/client/hooks/useLocalePercentage.ts @@ -0,0 +1,11 @@ +import { useLanguage } from '../contexts/TranslationContext'; +import { getLocalePercentage } from '../lib/getLocalePercentage'; + +export const useLocalePercentage = ( + total: number, + fraction: number, + decimalCount: number | undefined, +): string => { + const locale = useLanguage(); + return getLocalePercentage(locale, total, fraction, decimalCount); +}; diff --git a/client/lib/getLocalePercentage.ts b/client/lib/getLocalePercentage.ts new file mode 100644 index 00000000000..fe8c87de5c6 --- /dev/null +++ b/client/lib/getLocalePercentage.ts @@ -0,0 +1,14 @@ +export const getLocalePercentage = ( + locale: string, + total: number, + fraction: number, + decimalCount = 2, +): string => { + const option = { + style: 'percent', + minimumFractionDigits: decimalCount, + maximumFractionDigits: decimalCount, + }; + + return new Intl.NumberFormat(locale, option).format(fraction / total); +}; diff --git a/client/startup/banners.ts b/client/startup/banners.ts index b5141c25f15..57e0d9c7daa 100644 --- a/client/startup/banners.ts +++ b/client/startup/banners.ts @@ -7,7 +7,7 @@ import { IBanner, BannerPlatform } from '../../definition/IBanner'; import * as banners from '../lib/banners'; const fetchInitialBanners = async (): Promise => { - const response = (await APIClient.get('v1/banners.getNew', { + const response = (await APIClient.get('v1/banners', { platform: BannerPlatform.Web, })) as { banners: IBanner[]; @@ -21,14 +21,17 @@ const fetchInitialBanners = async (): Promise => { } }; -const handleNewBanner = async (event: { bannerId: string }): Promise => { - const response = (await APIClient.get('v1/banners.getNew', { +const handleBanner = async (event: { bannerId: string }): Promise => { + const response = (await APIClient.get(`v1/banners/${event.bannerId}`, { platform: BannerPlatform.Web, - bid: event.bannerId, })) as { banners: IBanner[]; }; + if (!response.banners.length) { + return banners.closeById(event.bannerId); + } + for (const banner of response.banners) { banners.open({ ...banner.view, @@ -40,10 +43,10 @@ const handleNewBanner = async (event: { bannerId: string }): Promise => { const watchBanners = (): (() => void) => { fetchInitialBanners(); - Notifications.onLogged('new-banner', handleNewBanner); + Notifications.onLogged('banner-changed', handleBanner); return (): void => { - Notifications.unLogged(handleNewBanner); + Notifications.unLogged(handleBanner); banners.clear(); }; }; diff --git a/client/views/admin/info/DeploymentCard.js b/client/views/admin/info/DeploymentCard.tsx similarity index 63% rename from client/views/admin/info/DeploymentCard.js rename to client/views/admin/info/DeploymentCard.tsx index 38a516244cf..cb8c6a5cc7a 100644 --- a/client/views/admin/info/DeploymentCard.js +++ b/client/views/admin/info/DeploymentCard.tsx @@ -1,26 +1,33 @@ -import { Skeleton, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; +import { IInstance } from '../../../../definition/IInstance'; +import { IServerInfo } from '../../../../definition/IServerInfo'; +import { IStats } from '../../../../definition/IStats'; import Card from '../../../components/Card'; import { useSetModal } from '../../../contexts/ModalContext'; import { useTranslation } from '../../../contexts/TranslationContext'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; import InstancesModal from './InstancesModal'; -const DeploymentCard = memo(function DeploymentCard({ info, statistics, instances, isLoading }) { +type DeploymentCardProps = { + info: IServerInfo; + instances: Array; + statistics: IStats; +}; + +const DeploymentCard = ({ info, statistics, instances }: DeploymentCardProps): ReactElement => { const t = useTranslation(); const formatDateAndTime = useFormatDateAndTime(); const setModal = useSetModal(); const { commit = {} } = info; - const s = (fn) => (isLoading ? : fn()); - const appsEngineVersion = info && info.marketplaceApiVersion; const handleInstancesModal = useMutableCallback(() => { - setModal( setModal()} />); + setModal( setModal()} />); }); return ( @@ -30,11 +37,11 @@ const DeploymentCard = memo(function DeploymentCard({ info, statistics, instance {t('Version')} - {s(() => statistics.version)} + {statistics.version} {t('Deployment_ID')} - {s(() => statistics.uniqueId)} + {statistics.uniqueId} {appsEngineVersion && ( @@ -44,34 +51,28 @@ const DeploymentCard = memo(function DeploymentCard({ info, statistics, instance )} {t('Node_version')} - {s(() => statistics.process.nodeVersion)} + {statistics.process.nodeVersion} {t('DB_Migration')} - {s( - () => - `${statistics.migration.version} (${formatDateAndTime( - statistics.migration.lockedAt, - )})`, - )} + {`${statistics.migration.version} (${formatDateAndTime( + statistics.migration.lockedAt, + )})`} {t('MongoDB')} - {s( - () => - `${statistics.mongoVersion} / ${statistics.mongoStorageEngine} (oplog ${ - statistics.oplogEnabled ? t('Enabled') : t('Disabled') - })`, - )} + {`${statistics.mongoVersion} / ${statistics.mongoStorageEngine} (oplog ${ + statistics.oplogEnabled ? t('Enabled') : t('Disabled') + })`} {t('Commit_details')} - {t('HEAD')}: ({s(() => (commit.hash ? commit.hash.slice(0, 9) : ''))})
- {t('Branch')}: {s(() => commit.branch)} + {t('github_HEAD')}: ({commit.hash ? commit.hash.slice(0, 9) : ''})
+ {t('Branch')}: {commit.branch}
{t('PID')} - {s(() => statistics.process.pid)} + {statistics.process.pid}
@@ -87,6 +88,6 @@ const DeploymentCard = memo(function DeploymentCard({ info, statistics, instance )} ); -}); +}; -export default DeploymentCard; +export default memo(DeploymentCard); diff --git a/client/views/admin/info/InformationPage.tsx b/client/views/admin/info/InformationPage.tsx new file mode 100644 index 00000000000..5f0e1b5efa4 --- /dev/null +++ b/client/views/admin/info/InformationPage.tsx @@ -0,0 +1,117 @@ +import { Box, Button, ButtonGroup, Callout, Icon, Margins } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import React, { memo } from 'react'; + +import type { IInstance } from '../../../../definition/IInstance'; +import type { IServerInfo } from '../../../../definition/IServerInfo'; +import type { IStats } from '../../../../definition/IStats'; +import SeatsCard from '../../../../ee/client/views/admin/info/SeatsCard'; +import { DOUBLE_COLUMN_CARD_WIDTH } from '../../../components/Card'; +import Page from '../../../components/Page'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import DeploymentCard from './DeploymentCard'; +import FederationCard from './FederationCard'; +import LicenseCard from './LicenseCard'; +import UsageCard from './UsageCard'; + +type InformationPageProps = { + canViewStatistics: boolean; + info: IServerInfo; + statistics: IStats; + instances: Array; + onClickRefreshButton: () => void; + onClickDownloadInfo: () => void; +}; + +const InformationPage = memo(function InformationPage({ + canViewStatistics, + info, + statistics, + instances, + onClickRefreshButton, + onClickDownloadInfo, +}: InformationPageProps) { + const t = useTranslation(); + + const { ref, contentBoxSize: { inlineSize = DOUBLE_COLUMN_CARD_WIDTH } = {} } = + useResizeObserver(); + + const isSmall = inlineSize < DOUBLE_COLUMN_CARD_WIDTH; + + if (!info) { + return null; + } + + const alertOplogForMultipleInstances = + statistics && statistics.instanceCount > 1 && !statistics.oplogEnabled; + + return ( + + + {canViewStatistics && ( + + + + + )} + + + + + {alertOplogForMultipleInstances && ( + + +

+ {t( + 'Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details', + )} +

+

+ + {t('Click_here_for_more_info')} + +

+
+
+ )} + + + + + + + + + + +
+
+
+ ); +}); + +export default InformationPage; diff --git a/client/views/admin/info/InformationRoute.js b/client/views/admin/info/InformationRoute.tsx similarity index 68% rename from client/views/admin/info/InformationRoute.js rename to client/views/admin/info/InformationRoute.tsx index 3e0abbfb8ac..151c296a124 100644 --- a/client/views/admin/info/InformationRoute.js +++ b/client/views/admin/info/InformationRoute.tsx @@ -1,30 +1,36 @@ import { Callout, ButtonGroup, Button, Icon } from '@rocket.chat/fuselage'; -import React, { useState, useEffect, memo } from 'react'; +import React, { useState, useEffect, memo, ReactElement } from 'react'; +import { IStats } from '../../../../definition/IStats'; import NotAuthorizedPage from '../../../components/NotAuthorizedPage'; import Page from '../../../components/Page'; +import PageSkeleton from '../../../components/PageSkeleton'; import { usePermission } from '../../../contexts/AuthorizationContext'; import { useMethod, useServerInformation, useEndpoint } from '../../../contexts/ServerContext'; import { useTranslation } from '../../../contexts/TranslationContext'; import { downloadJsonAs } from '../../../lib/download'; -import NewInformationPage from './NewInformationPage'; +import InformationPage from './InformationPage'; -const InformationRoute = memo(function InformationRoute() { +type fetchStatisticsCallback = ((params: { refresh: boolean }) => void) | (() => void); + +const InformationRoute = (): ReactElement => { const t = useTranslation(); const canViewStatistics = usePermission('view-statistics'); const [isLoading, setLoading] = useState(true); - const [error, setError] = useState(); - const [statistics, setStatistics] = useState({}); + const [error, setError] = useState(false); + const [statistics, setStatistics] = useState(); const [instances, setInstances] = useState([]); - const [fetchStatistics, setFetchStatistics] = useState(() => () => ({})); + const [fetchStatistics, setFetchStatistics] = useState( + () => (): void => undefined, + ); const getStatistics = useEndpoint('GET', 'statistics'); const getInstances = useMethod('instances/get'); useEffect(() => { let didCancel = false; - const fetchStatistics = async ({ refresh = false } = {}) => { + const fetchStatistics = async ({ refresh = false } = {}): Promise => { setLoading(true); setError(false); @@ -50,14 +56,14 @@ const InformationRoute = memo(function InformationRoute() { fetchStatistics(); - return () => { + return (): void => { didCancel = true; }; }, [canViewStatistics, getInstances, getStatistics]); const info = useServerInformation(); - const handleClickRefreshButton = () => { + const handleClickRefreshButton = (): void => { if (isLoading) { return; } @@ -65,14 +71,18 @@ const InformationRoute = memo(function InformationRoute() { fetchStatistics({ refresh: true }); }; - const handleClickDownloadInfo = () => { + const handleClickDownloadInfo = (): void => { if (isLoading) { return; } downloadJsonAs(statistics, 'statistics'); }; - if (error) { + if (isLoading) { + return ; + } + + if (error || !statistics) { return ( @@ -83,9 +93,7 @@ const InformationRoute = memo(function InformationRoute() { - - {t('Error_loading_pages')} {/* : {error.message || error.stack}*/} - + {t('Error_loading_pages')} ); @@ -93,9 +101,8 @@ const InformationRoute = memo(function InformationRoute() { if (canViewStatistics) { return ( - ; -}); +}; -export default InformationRoute; +export default memo(InformationRoute); diff --git a/client/views/admin/info/InstancesCard.js b/client/views/admin/info/InstancesCard.js deleted file mode 100644 index 98dcbc5df2f..00000000000 --- a/client/views/admin/info/InstancesCard.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Box, ButtonGroup, Button } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React from 'react'; - -import Card from '../../../components/Card'; -import { useSetModal } from '../../../contexts/ModalContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import InstancesModal from './InstancesModal'; -import UsagePieGraph from './UsagePieGraph'; - -const InstancesCard = ({ instances }) => { - const t = useTranslation(); - - const setModal = useSetModal(); - - const handleModal = useMutableCallback(() => { - setModal( setModal()} />); - }); - - return ( - - {t('Instances')} - - - - - - - - - - - - - - - - ); -}; - -export default InstancesCard; diff --git a/client/views/admin/info/LicenseCard.js b/client/views/admin/info/LicenseCard.js index efaf431f7c7..41b1047ef59 100644 --- a/client/views/admin/info/LicenseCard.js +++ b/client/views/admin/info/LicenseCard.js @@ -1,4 +1,4 @@ -import { Box, ButtonGroup, Button, Skeleton, Margins } from '@rocket.chat/fuselage'; +import { ButtonGroup, Button, Skeleton, Margins } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React from 'react'; @@ -11,9 +11,8 @@ import { AsyncStatePhase } from '../../../hooks/useAsyncState'; import { useEndpointData } from '../../../hooks/useEndpointData'; import Feature from './Feature'; import OfflineLicenseModal from './OfflineLicenseModal'; -import UsagePieGraph from './UsagePieGraph'; -const LicenseCard = ({ statistics, isLoading }) => { +const LicenseCard = () => { const t = useTranslation(); const setModal = useSetModal(); @@ -26,7 +25,7 @@ const LicenseCard = ({ statistics, isLoading }) => { const { value, phase, error } = useEndpointData('licenses.get'); const endpointLoading = phase === AsyncStatePhase.LOADING; - const { maxActiveUsers = 0, modules = [] } = + const { modules = [] } = endpointLoading || error || !value.licenses.length ? {} : value.licenses[0]; const hasEngagement = modules.includes('engagement-dashboard'); @@ -74,22 +73,6 @@ const LicenseCard = ({ statistics, isLoading }) => { )} - - {t('Usage')} - - {isLoading ? ( - - ) : ( - - )} - - diff --git a/client/views/admin/info/NewInformationPage.js b/client/views/admin/info/NewInformationPage.js index c57050bc0f7..0e3ba77c1e0 100644 --- a/client/views/admin/info/NewInformationPage.js +++ b/client/views/admin/info/NewInformationPage.js @@ -2,6 +2,7 @@ import { Box, Button, ButtonGroup, Callout, Icon, Margins } from '@rocket.chat/f import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import React, { memo } from 'react'; +import SeatsCard from '../../../../ee/client/views/admin/info/SeatsCard'; import { DOUBLE_COLUMN_CARD_WIDTH } from '../../../components/Card'; import Page from '../../../components/Page'; import { useTranslation } from '../../../contexts/TranslationContext'; @@ -100,6 +101,7 @@ const InformationPage = memo(function InformationPage({ + {/* {!!instances.length && } */} {/* */} diff --git a/client/views/admin/info/PushCard.js b/client/views/admin/info/PushCard.js deleted file mode 100644 index bf2f7512844..00000000000 --- a/client/views/admin/info/PushCard.js +++ /dev/null @@ -1,40 +0,0 @@ -import { Box, ButtonGroup, Button } from '@rocket.chat/fuselage'; -import React from 'react'; -// import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; - -import Card from '../../../components/Card'; -import { useTranslation } from '../../../contexts/TranslationContext'; -// import { useSetModal } from '../../contexts/ModalContext'; -import UsagePieGraph from './UsagePieGraph'; -// import PlanTag from '../../components/basic/PlanTag'; -// import { useSetting } from '../../contexts/SettingsContext'; -// import { useHasLicense } from '../../../ee/client/hooks/useHasLicense'; -// import OfflineLicenseModal from './OfflineLicenseModal'; - -const PushCard = () => { - const t = useTranslation(); - - // const setModal = useSetModal(); - - return ( - - {t('Push_Notifications')} - - - - - - - - - - - - - - - - ); -}; - -export default PushCard; diff --git a/client/views/admin/info/UsageCard.js b/client/views/admin/info/UsageCard.tsx similarity index 66% rename from client/views/admin/info/UsageCard.js rename to client/views/admin/info/UsageCard.tsx index fe12542476c..8c70f9853f3 100644 --- a/client/views/admin/info/UsageCard.js +++ b/client/views/admin/info/UsageCard.tsx @@ -1,7 +1,8 @@ -import { Skeleton, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; +import { IStats } from '../../../../definition/IStats'; import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; import Card from '../../../components/Card'; import { UserStatus } from '../../../components/UserStatus'; @@ -10,8 +11,12 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize'; import TextSeparator from './TextSeparator'; -const UsageCard = memo(function UsageCard({ statistics, isLoading, vertical }) { - const s = (fn) => (isLoading ? : fn()); +type UsageCardProps = { + statistics: IStats; + vertical: boolean; +}; + +const UsageCard = ({ statistics, vertical }: UsageCardProps): ReactElement => { const t = useTranslation(); const formatMemorySize = useFormatMemorySize(); @@ -36,7 +41,7 @@ const UsageCard = memo(function UsageCard({ statistics, isLoading, vertical }) { {t('Total')} } - value={s(() => statistics.totalUsers)} + value={statistics.totalUsers} /> } - value={s(() => statistics.onlineUsers)} + value={statistics.onlineUsers} /> } - value={s(() => statistics.busyUsers)} + value={statistics.busyUsers} /> } - value={s(() => statistics.awayUsers)} + value={statistics.awayUsers} /> } - value={s(() => statistics.offlineUsers)} + value={statistics.offlineUsers} /> {t('Types_and_Distribution')} - statistics.totalConnectedUsers)} /> - statistics.activeUsers)} - /> - statistics.activeGuests)} - /> - statistics.nonActiveUsers)} - /> - statistics.appUsers)} /> + + + + + {t('Uploads')} - statistics.uploadsTotal)} - /> + formatMemorySize(statistics.uploadsTotalSize))} + value={formatMemorySize(statistics.uploadsTotalSize)} /> @@ -122,7 +115,7 @@ const UsageCard = memo(function UsageCard({ statistics, isLoading, vertical }) { {t('Stats_Total_Rooms')} } - value={s(() => statistics.totalRooms)} + value={statistics.totalRooms} /> {t('Stats_Total_Channels')} } - value={s(() => statistics.totalChannels)} + value={statistics.totalChannels} /> {t('Stats_Total_Private_Groups')} } - value={s(() => statistics.totalPrivateGroups)} + value={statistics.totalPrivateGroups} /> {t('Stats_Total_Direct_Messages')} } - value={s(() => statistics.totalDirect)} + value={statistics.totalDirect} /> {t('Total_Discussions')} } - value={s(() => statistics.totalDiscussions)} + value={statistics.totalDiscussions} /> {t('Stats_Total_Livechat_Rooms')} } - value={s(() => statistics.totalLivechat)} + value={statistics.totalLivechat} /> {t('Messages')} - statistics.totalMessages)} - /> - statistics.totalThreads)} /> + + statistics.totalChannelMessages)} + value={statistics.totalChannelMessages} /> statistics.totalPrivateGroupMessages)} + value={statistics.totalPrivateGroupMessages} /> statistics.totalDirectMessages)} + value={statistics.totalDirectMessages} /> statistics.totalLivechatMessages)} + value={statistics.totalLivechatMessages} /> @@ -200,6 +190,6 @@ const UsageCard = memo(function UsageCard({ statistics, isLoading, vertical }) { ); -}); +}; -export default UsageCard; +export default memo(UsageCard); diff --git a/client/views/admin/info/UsagePieGraph.js b/client/views/admin/info/UsagePieGraph.js deleted file mode 100644 index 49bee07b7d3..00000000000 --- a/client/views/admin/info/UsagePieGraph.js +++ /dev/null @@ -1,67 +0,0 @@ -import { Pie } from '@nivo/pie'; -import { Box } from '@rocket.chat/fuselage'; -import colors from '@rocket.chat/fuselage-tokens/colors'; -import React, { useMemo, useCallback } from 'react'; - -const graphColors = (color) => ({ used: color || colors.b500, free: colors.n300 }); - -const UsageGraph = ({ used = 0, total = 0, label, color, size }) => { - const parsedData = useMemo( - () => [ - { - id: 'used', - label: 'used', - value: used, - }, - { - id: 'free', - label: 'free', - value: total - used, - }, - ], - [total, used], - ); - - const getColor = useCallback((data) => graphColors(color)[data.id], [color]); - - const unlimited = total === 0; - - return ( - - - - - - {unlimited ? '∞' : `${Number((100 / total) * used).toFixed(2)}%`} - - - - - - {used} - {' '} - / {unlimited ? '∞' : total} - - {label} - - ); -}; - -export default UsageGraph; diff --git a/client/views/admin/info/UsagePieGraph.tsx b/client/views/admin/info/UsagePieGraph.tsx new file mode 100644 index 00000000000..05c558f665a --- /dev/null +++ b/client/views/admin/info/UsagePieGraph.tsx @@ -0,0 +1,100 @@ +import { Pie, DatumId } from '@nivo/pie'; +import { Box } from '@rocket.chat/fuselage'; +import colors from '@rocket.chat/fuselage-tokens/colors'; +import React, { useMemo, useCallback, ReactElement, CSSProperties, ReactNode } from 'react'; + +import { useLocalePercentage } from '../../../hooks/useLocalePercentage'; + +type GraphColorsReturn = { [key: string]: string }; + +const graphColors = (color: CSSProperties['color']): GraphColorsReturn => ({ + used: color || colors.b500, + free: colors.n300, +}); + +type UsageGraphProps = { + used: number; + total: number; + label: ReactNode; + color?: string; + size: number; +}; + +type GraphData = Array<{ + id: string; + label: string; + value: number; +}>; + +const UsageGraph = ({ used = 0, total = 0, label, color, size }: UsageGraphProps): ReactElement => { + const parsedData = useMemo( + (): GraphData => [ + { + id: 'used', + label: 'used', + value: used, + }, + { + id: 'free', + label: 'free', + value: total - used, + }, + ], + [total, used], + ); + + const getColor = useCallback( + (datum: { id: DatumId } | undefined) => { + if (!datum || typeof datum.id !== 'string') { + return ''; + } + return graphColors(color)[datum.id]; + }, + [color], + ); + + const unlimited = total === 0; + + const localePercentage = useLocalePercentage(total, used, 0); + + return ( + + + + + + {unlimited ? '∞' : localePercentage} + + + + + + {used} + {' '} + / {unlimited ? '∞' : total} + + + {label} + + + ); +}; + +export default UsageGraph; diff --git a/client/views/admin/users/AddUser.js b/client/views/admin/users/AddUser.js index 72578264d18..f553202e4b2 100644 --- a/client/views/admin/users/AddUser.js +++ b/client/views/admin/users/AddUser.js @@ -9,7 +9,7 @@ import { useEndpointData } from '../../../hooks/useEndpointData'; import { useForm } from '../../../hooks/useForm'; import UserForm from './UserForm'; -export function AddUser({ roles, reloadTable, ...props }) { +export function AddUser({ roles, onReload, ...props }) { const t = useTranslation(); const router = useRoute('admin-users'); @@ -93,7 +93,7 @@ export function AddUser({ roles, reloadTable, ...props }) { const result = await saveAction(); if (result.success) { goToUser(result.user._id); - reloadTable(); + onReload(); } }); diff --git a/client/views/admin/users/EditUser.js b/client/views/admin/users/EditUser.js index 9e58c96c514..687f3d9f60b 100644 --- a/client/views/admin/users/EditUser.js +++ b/client/views/admin/users/EditUser.js @@ -26,7 +26,7 @@ const getInitialValue = (data) => ({ statusText: data.statusText ?? '', }); -function EditUser({ data, roles, reloadTable, ...props }) { +function EditUser({ data, roles, onReload, ...props }) { const t = useTranslation(); const [avatarObj, setAvatarObj] = useState(); @@ -143,7 +143,7 @@ function EditUser({ data, roles, reloadTable, ...props }) { if (avatarObj) { await updateAvatar(); } - reloadTable(); + onReload(); goToUser(data._id); } }, [avatarObj, data._id, goToUser, saveAction, updateAvatar, values, errors, validationKeys]); diff --git a/client/views/admin/users/UserInfo.js b/client/views/admin/users/UserInfo.js index 6a526577ac6..0c7808ec102 100644 --- a/client/views/admin/users/UserInfo.js +++ b/client/views/admin/users/UserInfo.js @@ -14,7 +14,7 @@ import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified'; import UserInfo from '../../room/contextualBar/UserInfo/UserInfo'; import { UserInfoActions } from './UserInfoActions'; -export function UserInfoWithData({ uid, username, reloadTable, ...props }) { +export function UserInfoWithData({ uid, username, onReload, ...props }) { const t = useTranslation(); const showRealNames = useSetting('UI_Use_Real_Name'); const approveManuallyUsers = useSetting('Accounts_ManuallyApproveNewUsers'); @@ -23,7 +23,7 @@ export function UserInfoWithData({ uid, username, reloadTable, ...props }) { value: data, phase: state, error, - reload, + reload: reloadUserInfo, } = useEndpointData( 'users.info', useMemo( @@ -32,8 +32,10 @@ export function UserInfoWithData({ uid, username, reloadTable, ...props }) { ), ); - const onChange = useMutableCallback(() => reload()); - const onDelete = useMutableCallback(() => (reloadTable ? reloadTable() : null)); + const onChange = useMutableCallback(() => { + onReload(); + reloadUserInfo(); + }); const user = useMemo(() => { const { user } = data || { user: {} }; @@ -100,7 +102,6 @@ export function UserInfoWithData({ uid, username, reloadTable, ...props }) { _id={data.user._id} username={data.user.username} onChange={onChange} - onDelete={onDelete} /> ) } diff --git a/client/views/admin/users/UserInfoActions.js b/client/views/admin/users/UserInfoActions.js index ebc4fd2ffc4..a326997d747 100644 --- a/client/views/admin/users/UserInfoActions.js +++ b/client/views/admin/users/UserInfoActions.js @@ -13,7 +13,7 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useActionSpread } from '../../hooks/useActionSpread'; import UserInfo from '../../room/contextualBar/UserInfo'; -export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange, onDelete }) => { +export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) => { const t = useTranslation(); const setModal = useSetModal(); @@ -39,7 +39,7 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange, on const handleDeletedUser = () => { setModal(); userRoute.push({}); - onDelete(); + onChange(); }; const confirmOwnerChanges = diff --git a/client/views/admin/users/UserPageHeaderContent.tsx b/client/views/admin/users/UserPageHeaderContent.tsx new file mode 100644 index 00000000000..3dcaa9d0491 --- /dev/null +++ b/client/views/admin/users/UserPageHeaderContent.tsx @@ -0,0 +1,33 @@ +import { Button, ButtonGroup, Icon } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import { useRoute } from '../../../contexts/RouterContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; + +const UserPageHeaderContent = (): ReactElement => { + const usersRoute = useRoute('admin-users'); + const t = useTranslation(); + + const handleNewButtonClick = (): void => { + usersRoute.push({ context: 'new' }); + }; + + const handleInviteButtonClick = (): void => { + usersRoute.push({ context: 'invite' }); + }; + + return ( + <> + + + + + + ); +}; + +export default UserPageHeaderContent; diff --git a/client/views/admin/users/UsersPage.js b/client/views/admin/users/UsersPage.js index 425c9afd012..87757975250 100644 --- a/client/views/admin/users/UsersPage.js +++ b/client/views/admin/users/UsersPage.js @@ -1,16 +1,18 @@ -import { Button, ButtonGroup, Icon } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import UserPageHeaderContentWithSeatsCap from '../../../../ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap'; +import { useSeatsCap } from '../../../../ee/client/views/admin/users/useSeatsCap'; import Page from '../../../components/Page'; import VerticalBar from '../../../components/VerticalBar'; -import { useRoute, useCurrentRoute } from '../../../contexts/RouterContext'; +import { useRoute, useRouteParameter } from '../../../contexts/RouterContext'; import { useTranslation } from '../../../contexts/TranslationContext'; import { useEndpointData } from '../../../hooks/useEndpointData'; import { AddUser } from './AddUser'; import EditUserWithData from './EditUserWithData'; import { InviteUsers } from './InviteUsers'; import { UserInfoWithData } from './UserInfo'; +import UserPageHeaderContent from './UserPageHeaderContent'; import UsersTable from './UsersTable'; const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); @@ -47,22 +49,26 @@ const useQuery = ({ text, itemsPerPage, current }, sortFields) => ); function UsersPage() { - const t = useTranslation(); - + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + const seatsCap = useSeatsCap(); const usersRoute = useRoute('admin-users'); - const handleVerticalBarCloseButtonClick = () => { - usersRoute.push({}); - }; + useEffect(() => { + if (!context || !seatsCap) { + return; + } - const handleNewButtonClick = () => { - usersRoute.push({ context: 'new' }); - }; + if (seatsCap.activeUsers >= seatsCap.maxActiveUsers && !['edit', 'info'].includes(context)) { + usersRoute.push({}); + } + }, [context, seatsCap, usersRoute]); - const handleInviteButtonClick = () => { - usersRoute.push({ context: 'invite' }); - }; + const t = useTranslation(); + const handleVerticalBarCloseButtonClick = () => { + usersRoute.push({}); + }; const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 }); const [sort, setSort] = useState([ ['name', 'asc'], @@ -72,21 +78,23 @@ function UsersPage() { const debouncedParams = useDebouncedValue(params, 500); const debouncedSort = useDebouncedValue(sort, 500); const query = useQuery(debouncedParams, debouncedSort); - const { value: data = {}, reload } = useEndpointData('users.list', query); - const [, { context, id }] = useCurrentRoute(); + const { value: data = {}, reload: reloadList } = useEndpointData('users.list', query); + + const reload = () => { + seatsCap?.reload(); + reloadList(); + }; return ( - - - - + {seatsCap && + (seatsCap.maxActiveUsers < Number.POSITIVE_INFINITY ? ( + + ) : ( + + ))} - {context === 'info' && } - {context === 'edit' && } - {context === 'new' && } + {context === 'info' && } + {context === 'edit' && } + {context === 'new' && } {context === 'invite' && } )} diff --git a/client/views/banners/UiKitBanner.tsx b/client/views/banners/UiKitBanner.tsx index 8c5b3421ac6..583d5e41609 100644 --- a/client/views/banners/UiKitBanner.tsx +++ b/client/views/banners/UiKitBanner.tsx @@ -1,15 +1,27 @@ /* eslint-disable new-cap */ import { Banner, Icon } from '@rocket.chat/fuselage'; -import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit'; -import React, { Context, FC, useMemo } from 'react'; +import { + kitContext, + // @ts-ignore + bannerParser, + UiKitBanner as renderUiKitBannerBlocks, +} from '@rocket.chat/fuselage-ui-kit'; +import React, { Context, FC, useMemo, ReactNode } from 'react'; -// import { useEndpoint } from '../../contexts/ServerContext'; import { UiKitBannerProps, UiKitBannerPayload } from '../../../definition/UIKit'; import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction'; import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose'; import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager'; +import MarkdownText from '../../components/MarkdownText'; import * as banners from '../../lib/banners'; +// TODO: move this to fuselage-ui-kit itself +const mrkdwn = ({ text }: { text: string } = { text: '' }): ReactNode => ( + +); + +bannerParser.mrkdwn = mrkdwn; + const UiKitBanner: FC = ({ payload }) => { const state = useUIKitStateManager(payload); diff --git a/definition/IBanner.ts b/definition/IBanner.ts index 0a63479aa44..e895791f5e3 100644 --- a/definition/IBanner.ts +++ b/definition/IBanner.ts @@ -14,8 +14,18 @@ export interface IBanner extends IRocketChatRecord { createdBy: Pick; createdAt: Date; view: UiKitBannerPayload; + active?: boolean; + inactivedAt?: Date; + snapshot?: string; } +export type InactiveBanner = IBanner & { + active: false; + inactivedAt: Date; +}; + +export const isInactiveBanner = (banner: IBanner): banner is InactiveBanner => banner.active === false; + export interface IBannerDismiss extends IRocketChatRecord { userId: IUser['_id']; // user receiving the banner dismissed bannerId: IBanner['_id']; // banner dismissed diff --git a/definition/IInstance.ts b/definition/IInstance.ts new file mode 100644 index 00000000000..aeafd9f35e7 --- /dev/null +++ b/definition/IInstance.ts @@ -0,0 +1,21 @@ +export interface IInstance { + address: string; + currentStatus: { + connected: boolean; + retryCount: number; + retryTime: number; + status: string; + }; + instanceRecord: { + name: string; + pid: number; + _createdAt: Date; + _id: string; + _updatedAt: Date; + extraInformation: { + host: string; + nodeVersion: string; + port: string; + }; + }; +} diff --git a/definition/IServerInfo.ts b/definition/IServerInfo.ts new file mode 100644 index 00000000000..20962f37211 --- /dev/null +++ b/definition/IServerInfo.ts @@ -0,0 +1,22 @@ +export interface IServerInfo { + build: { + arch: string; + cpus: number; + date: string; + freeMemory: number; + nodeVersion: string; + osRelease: string; + platform: string; + totalMemory: number; + }; + commit: { + author?: string; + branch?: string; + date?: string; + hash?: string; + subject?: string; + tag?: string; + }; + marketplaceApiVersion: string; + version: string; +} diff --git a/definition/IStats.ts b/definition/IStats.ts new file mode 100644 index 00000000000..a8b28310174 --- /dev/null +++ b/definition/IStats.ts @@ -0,0 +1,78 @@ +import type { CpuInfo } from 'os'; + +import type { ITeamStats } from './ITeam'; + +export interface IStats { + wizard: Record; + uniqueId: string; + installedAt?: string; + version?: string; + tag?: string; + branch?: string; + totalUsers: number; + activeUsers: number; + activeGuests: number; + nonActiveUsers: number; + appUsers: number; + onlineUsers: number; + awayUsers: number; + busyUsers: number; + totalConnectedUsers: number; + offlineUsers: number; + userLanguages: Record; + totalRooms: number; + totalChannels: number; + totalPrivateGroups: number; + totalDirect: number; + totalLivechat: number; + totalDiscussions: number; + totalThreads: number; + teams: ITeamStats; + totalLivechatVisitors: number; + totalLivechatAgents: number; + livechatEnabled: boolean; + totalChannelMessages: number; + totalPrivateGroupMessages: number; + totalDirectMessages: number; + totalLivechatMessages: number; + totalMessages: number; + federatedServers: number; + federatedUsers: number; + lastLogin: string; + lastMessageSentAt: string; + lastSeenSubscription: string; + os: { + type: string; + platform: NodeJS.Platform; + arch: string; + release: string; + uptime: number; + loadavg: number[]; + totalmem: number; + freemem: number; + cpus: CpuInfo[]; + }; + process: { + nodeVersion: string; + pid: number; + uptime: number; + }; + deploy: { + method: string; + platform: string; + }; + enterpriseReady: boolean; + uploadsTotal: number; + uploadsTotalSize: number; + migration: { + _id: 'control'; + locked: boolean; + version: number; + buildAt: string; + lockedAt: string; + }; + instanceCount: number; + oplogEnabled: boolean; + mongoVersion: string; + mongoStorageEngine: string; +} diff --git a/definition/utils.ts b/definition/utils.ts index e075ac7dfff..90e7f59df57 100644 --- a/definition/utils.ts +++ b/definition/utils.ts @@ -1,3 +1,5 @@ +export type Optional = Omit & Partial; + export type ExtractKeys = T[K] extends U ? K : never; export type ValueOf = T[keyof T]; diff --git a/ee/app/license/server/getSeatsRequestLink.ts b/ee/app/license/server/getSeatsRequestLink.ts new file mode 100644 index 00000000000..b130fb766cc --- /dev/null +++ b/ee/app/license/server/getSeatsRequestLink.ts @@ -0,0 +1,30 @@ +import { Settings, Users } from '../../../../app/models/server'; +import { ISetting } from '../../../../definition/ISetting'; + +type WizardSettings = Array; + +const url = 'https://rocket.chat/sales-contact'; + +export const getSeatsRequestLink = (): string => { + const workspaceId: ISetting | undefined = Settings.findOneById('Cloud_Workspace_Id'); + const activeUsers = Users.getActiveLocalUserCount(); + const wizardSettings: WizardSettings = Settings.findSetupWizardSettings().fetch(); + + const newUrl = new URL(url); + + if (workspaceId?.value) { + newUrl.searchParams.append('workspaceId', String(workspaceId.value)); + } + + if (activeUsers) { + newUrl.searchParams.append('activeUsers', String(activeUsers)); + } + + wizardSettings + .filter(({ _id, value }) => ['Industry', 'Country', 'Size'].includes(_id) && value) + .forEach((setting) => { + newUrl.searchParams.append(setting._id.toLowerCase(), String(setting.value)); + }); + + return newUrl.toString(); +}; diff --git a/ee/app/license/server/getStatistics.ts b/ee/app/license/server/getStatistics.ts index 1c90de04507..8ae86292a07 100644 --- a/ee/app/license/server/getStatistics.ts +++ b/ee/app/license/server/getStatistics.ts @@ -1,16 +1,20 @@ import { getModules, getTags } from './license'; +import { Analytics } from '../../../../server/sdk'; type ENTERPRISE_STATISTICS = { modules: string[]; tags: string[]; + seatRequests: number; } export function getStatistics(): ENTERPRISE_STATISTICS { const modules = getModules(); const tags = getTags().map(({ name }) => name); + const seatRequests = Promise.await(Analytics.getSeatRequestCount()); return { modules, tags, + seatRequests, }; } diff --git a/ee/app/license/server/license.ts b/ee/app/license/server/license.ts index 4c33299e96e..ad0b962759f 100644 --- a/ee/app/license/server/license.ts +++ b/ee/app/license/server/license.ts @@ -28,6 +28,7 @@ export interface IValidLicense { } let maxGuestUsers = 0; +let maxActiveUsers = 0; class LicenseClass { private url: string|null = null; @@ -80,10 +81,6 @@ class LicenseClass { }); } - private _hasValidNumberOfActiveUsers(maxActiveUsers: number): boolean { - return Users.getActiveLocalUserCount() <= maxActiveUsers; - } - private _addTags(license: ILicense): void { // if no tag present, it means it is an old license, so try check for bundles and use them as tags if (typeof license.tag === 'undefined') { @@ -178,17 +175,14 @@ class LicenseClass { return item; } - if (license.maxActiveUsers && !this._hasValidNumberOfActiveUsers(license.maxActiveUsers)) { - item.valid = false; - console.error(`#### License error: over seats, max allowed ${ license.maxActiveUsers }, current active users ${ Users.getActiveLocalUserCount() }`); - this._invalidModules(license.modules); - return item; - } - if (license.maxGuestUsers > maxGuestUsers) { maxGuestUsers = license.maxGuestUsers; } + if (license.maxActiveUsers > maxActiveUsers) { + maxActiveUsers = license.maxActiveUsers; + } + this._validModules(license.modules); this._addTags(license); @@ -203,6 +197,14 @@ class LicenseClass { this.showLicenses(); } + canAddNewUser(): boolean { + if (!maxActiveUsers) { + return true; + } + + return maxActiveUsers > Users.getActiveLocalUserCount(); + } + showLicenses(): void { if (!process.env.LICENSE_DEBUG || process.env.LICENSE_DEBUG === 'false') { return; @@ -286,6 +288,10 @@ export function getMaxGuestUsers(): number { return maxGuestUsers; } +export function getMaxActiveUsers(): number { + return maxActiveUsers; +} + export function getLicenses(): IValidLicense[] { return License.getLicenses(); } @@ -298,6 +304,10 @@ export function getTags(): ILicenseTag[] { return License.getTags(); } +export function canAddNewUser(): boolean { + return License.canAddNewUser(); +} + export function onLicense(feature: string, cb: (...args: any[]) => void): void { if (hasLicense(feature)) { return cb(); diff --git a/ee/app/license/server/maxSeatsBanners.ts b/ee/app/license/server/maxSeatsBanners.ts new file mode 100644 index 00000000000..7d03d57e918 --- /dev/null +++ b/ee/app/license/server/maxSeatsBanners.ts @@ -0,0 +1,116 @@ +import { Meteor } from 'meteor/meteor'; +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'; + +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: Meteor.absoluteUrl('/requestSeats'), + }), + 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: Meteor.absoluteUrl('/requestSeats'), + }), + 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/client/views/admin/info/SeatsCard.tsx b/ee/client/views/admin/info/SeatsCard.tsx new file mode 100644 index 00000000000..77744483520 --- /dev/null +++ b/ee/client/views/admin/info/SeatsCard.tsx @@ -0,0 +1,67 @@ +import { Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage'; +import colors from '@rocket.chat/fuselage-tokens/colors'; +import React, { ReactElement } from 'react'; + +import Card from '../../../../../client/components/Card'; +import ExternalLink from '../../../../../client/components/ExternalLink'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import UsagePieGraph from '../../../../../client/views/admin/info/UsagePieGraph'; +import { useRequestSeatsLink } from '../users/useRequestSeatsLink'; +import { useSeatsCap } from '../users/useSeatsCap'; + +const SeatsCard = (): ReactElement | null => { + const t = useTranslation(); + const seatsCap = useSeatsCap(); + const requestSeatsLink = useRequestSeatsLink(); + + const seatsLeft = seatsCap && Math.max(seatsCap.maxActiveUsers - seatsCap.activeUsers, 0); + + const isNearLimit = seatsCap && seatsCap.activeUsers / seatsCap.maxActiveUsers >= 0.8; + + const color = isNearLimit ? colors.r500 : undefined; + + if (seatsCap && seatsCap.maxActiveUsers === Infinity) { + return null; + } + + return ( + + {t('Seats_usage')} + + + + + {!seatsCap ? ( + + ) : ( + {t('Seats_Available', { seatsLeft })}} + used={seatsCap.activeUsers} + total={seatsCap.maxActiveUsers} + size={140} + color={color} + /> + )} + + + + + + + + + + + + + ); +}; + +export default SeatsCard; diff --git a/ee/client/views/admin/users/CloseToSeatsCapModal.tsx b/ee/client/views/admin/users/CloseToSeatsCapModal.tsx new file mode 100644 index 00000000000..3577f85a034 --- /dev/null +++ b/ee/client/views/admin/users/CloseToSeatsCapModal.tsx @@ -0,0 +1,51 @@ +import { Modal, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import ExternalLink from '../../../../../client/components/ExternalLink'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import MemberCapUsage from './SeatsCapUsage'; + +type CloseToSeatsCapModalProps = { + members: number; + limit: number; + title: string; + requestSeatsLink: string; + onConfirm: () => void; + onClose: () => void; +}; + +const CloseToSeatsCapModal = ({ + members, + limit, + title, + onConfirm, + onClose, + requestSeatsLink, +}: CloseToSeatsCapModalProps): ReactElement => { + const t = useTranslation(); + return ( + + + {title} + + + + + {t('Close_to_seat_limit_warning')}{' '} + {t('Request_more_seats')} + + + + + + + + + + + ); +}; + +export default CloseToSeatsCapModal; diff --git a/ee/client/views/admin/users/ReachedSeatsCapModal.tsx b/ee/client/views/admin/users/ReachedSeatsCapModal.tsx new file mode 100644 index 00000000000..6313b7f8af2 --- /dev/null +++ b/ee/client/views/admin/users/ReachedSeatsCapModal.tsx @@ -0,0 +1,52 @@ +import { Icon, Modal, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import ExternalLink from '../../../../../client/components/ExternalLink'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import SeatsCapUsage from './SeatsCapUsage'; + +type ReachedSeatsCapModalProps = { + members: number; + limit: number; + requestSeatsLink: string; + onClose: () => void; +}; + +const ReachedSeatsCapModal = ({ + members, + limit, + onClose, + requestSeatsLink, +}: ReachedSeatsCapModalProps): ReactElement => { + const t = useTranslation(); + return ( + + + {t('Request_more_seats_title')} + + + + + {t('Request_more_seats_out_of_seats')} + + + {t('Request_more_seats_sales_team')} + + + + + + + + + + + + + ); +}; + +export default ReachedSeatsCapModal; diff --git a/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.stories.tsx b/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.stories.tsx new file mode 100644 index 00000000000..f229dd1bc3b --- /dev/null +++ b/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.stories.tsx @@ -0,0 +1,12 @@ +import React, { ReactElement } from 'react'; + +import SeatsCapUsage from './SeatsCapUsage'; + +export default { + title: 'ee/admin/users/SeatsCapUsage', + component: SeatsCapUsage, +}; + +export const _default = (): ReactElement => ; +export const CloseToLimit = (): ReactElement => ; +export const ReachedLimit = (): ReactElement => ; diff --git a/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.tsx b/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.tsx new file mode 100644 index 00000000000..f5461f255ad --- /dev/null +++ b/ee/client/views/admin/users/SeatsCapUsage/SeatsCapUsage.tsx @@ -0,0 +1,44 @@ +import { ProgressBar, Box } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; + +type SeatsCapUsageProps = { + limit: number; + members: number; +}; + +const SeatsCapUsage = ({ limit, members }: SeatsCapUsageProps): ReactElement => { + const t = useTranslation(); + const percentage = Math.max(0, Math.min((100 / limit) * members, 100)); + const closeToLimit = percentage >= 80; + const reachedLimit = percentage >= 100; + const color = closeToLimit ? 'danger-500' : 'success-500'; + const seatsLeft = Math.max(0, limit - members); + + return ( + + +
{t('Seats_Available', { seatsLeft })}
+ {`${members}/${limit}`} +
+ +
+ ); +}; + +export default SeatsCapUsage; diff --git a/ee/client/views/admin/users/SeatsCapUsage/index.ts b/ee/client/views/admin/users/SeatsCapUsage/index.ts new file mode 100644 index 00000000000..ee5eda4ceca --- /dev/null +++ b/ee/client/views/admin/users/SeatsCapUsage/index.ts @@ -0,0 +1 @@ +export { default } from './SeatsCapUsage'; diff --git a/ee/client/views/admin/users/SeatsCapUsage/useUsageLabel.ts b/ee/client/views/admin/users/SeatsCapUsage/useUsageLabel.ts new file mode 100644 index 00000000000..60212b7c7c5 --- /dev/null +++ b/ee/client/views/admin/users/SeatsCapUsage/useUsageLabel.ts @@ -0,0 +1,12 @@ +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; + +export const useUsageLabel = (percentage: number): string => { + const fixedPercentage = percentage.toFixed(0); + const t = useTranslation(); + + if (percentage >= 100) { + return t('Out_of_seats'); + } + + return `${fixedPercentage}% ${t('Usage')}`; +}; diff --git a/ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx b/ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx new file mode 100644 index 00000000000..73c0da09d56 --- /dev/null +++ b/ee/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx @@ -0,0 +1,129 @@ +import { Button, ButtonGroup, Icon, Margins } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import ExternalLink from '../../../../../client/components/ExternalLink'; +import { useSetModal } from '../../../../../client/contexts/ModalContext'; +import { useRoute } from '../../../../../client/contexts/RouterContext'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import CloseToSeatsCapModal from './CloseToSeatsCapModal'; +import ReachedSeatsCapModal from './ReachedSeatsCapModal'; +import SeatsCapUsage from './SeatsCapUsage'; +import { useRequestSeatsLink } from './useRequestSeatsLink'; + +type UserPageHeaderContentWithSeatsCapProps = { + activeUsers: number; + maxActiveUsers: number; +}; + +const UserPageHeaderContentWithSeatsCap = ({ + activeUsers, + maxActiveUsers, +}: UserPageHeaderContentWithSeatsCapProps): ReactElement => { + const seatsLinkUrl = useRequestSeatsLink(); + + const t = useTranslation(); + const usersRoute = useRoute('admin-users'); + + const setModal = useSetModal(); + const closeModal = (): void => setModal(null); + + const isCloseToLimit = (): boolean => { + const ratio = activeUsers / maxActiveUsers; + return ratio >= 0.8; + }; + + const hasReachedLimit = (): boolean => { + const ratio = activeUsers / maxActiveUsers; + return ratio >= 1; + }; + + const withPreventionOnReachedLimit = (fn: () => void) => (): void => { + if (typeof seatsLinkUrl !== 'string') { + return; + } + if (hasReachedLimit()) { + setModal( + , + ); + return; + } + + fn(); + }; + + const handleNewButtonClick = withPreventionOnReachedLimit(() => { + if (typeof seatsLinkUrl !== 'string') { + return; + } + if (isCloseToLimit()) { + setModal( + { + usersRoute.push({ context: 'new' }); + closeModal(); + }} + onClose={closeModal} + />, + ); + return; + } + + usersRoute.push({ context: 'new' }); + }); + + const handleInviteButtonClick = withPreventionOnReachedLimit(() => { + if (typeof seatsLinkUrl !== 'string') { + return; + } + if (isCloseToLimit()) { + setModal( + { + usersRoute.push({ context: 'invite' }); + closeModal(); + }} + onClose={closeModal} + />, + ); + return; + } + + usersRoute.push({ context: 'invite' }); + }); + + return ( + <> + + + + + + + + + + + + ); +}; + +export default UserPageHeaderContentWithSeatsCap; diff --git a/ee/client/views/admin/users/useRequestSeatsLink.ts b/ee/client/views/admin/users/useRequestSeatsLink.ts new file mode 100644 index 00000000000..285bab08af3 --- /dev/null +++ b/ee/client/views/admin/users/useRequestSeatsLink.ts @@ -0,0 +1,3 @@ +import { useAbsoluteUrl } from '../../../../../client/contexts/ServerContext'; + +export const useRequestSeatsLink = (): string => useAbsoluteUrl()('/requestSeats'); diff --git a/ee/client/views/admin/users/useSeatsCap.ts b/ee/client/views/admin/users/useSeatsCap.ts new file mode 100644 index 00000000000..338208ce95b --- /dev/null +++ b/ee/client/views/admin/users/useSeatsCap.ts @@ -0,0 +1,21 @@ +import { useEndpointData } from '../../../../../client/hooks/useEndpointData'; + +export const useSeatsCap = (): + | { + maxActiveUsers: number; + activeUsers: number; + reload: () => void; + } + | undefined => { + const { value, reload } = useEndpointData('licenses.maxActiveUsers'); + + if (!value) { + return undefined; + } + + return { + activeUsers: value.activeUsers, + maxActiveUsers: value.maxActiveUsers ?? Number.POSITIVE_INFINITY, + reload, + }; +}; diff --git a/ee/server/api/licenses.ts b/ee/server/api/licenses.ts index 54a187080c9..0972584c398 100644 --- a/ee/server/api/licenses.ts +++ b/ee/server/api/licenses.ts @@ -1,7 +1,7 @@ import { check } from 'meteor/check'; -import { ILicense, getLicenses, validateFormat, flatModules } from '../../app/license/server/license'; -import { Settings } from '../../../app/models/server'; +import { ILicense, getLicenses, validateFormat, flatModules, getMaxActiveUsers } from '../../app/license/server/license'; +import { Settings, Users } from '../../../app/models/server'; import { API } from '../../../app/api/server/api'; import { hasPermission } from '../../../app/authorization/server'; @@ -46,3 +46,12 @@ API.v1.addRoute('licenses.add', { authRequired: true }, { return API.v1.success(); }, }); + +API.v1.addRoute('licenses.maxActiveUsers', { authRequired: true }, { + get() { + const maxActiveUsers = getMaxActiveUsers() || null; + const activeUsers = Users.getActiveLocalUserCount(); + + return API.v1.success({ maxActiveUsers, activeUsers }); + }, +}); diff --git a/ee/server/index.js b/ee/server/index.js index 0cf4a961937..b25b60748d8 100644 --- a/ee/server/index.js +++ b/ee/server/index.js @@ -1,4 +1,5 @@ import './broker'; +import './startup'; import '../app/models'; import '../app/license/server/index'; @@ -11,6 +12,7 @@ import '../app/livechat-enterprise/server/index'; import '../app/settings/server/index'; import '../app/teams-mention/server/index'; import './api'; +import './requestSeatsRoute'; import './configuration/index'; import './local-services/ldap/service'; import './settings/index'; diff --git a/ee/server/requestSeatsRoute.ts b/ee/server/requestSeatsRoute.ts new file mode 100644 index 00000000000..209310df045 --- /dev/null +++ b/ee/server/requestSeatsRoute.ts @@ -0,0 +1,17 @@ +import { IncomingMessage, ServerResponse } from 'http'; + +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { getSeatsRequestLink } from '../app/license/server/getSeatsRequestLink'; +import { Analytics } from '../../server/sdk'; + +Meteor.startup(() => { + WebApp.connectHandlers.use('/requestSeats/', Meteor.bindEnvironment((_: IncomingMessage, res: ServerResponse) => { + const url = getSeatsRequestLink(); + + Analytics.saveSeatRequest(); + res.writeHead(302, { Location: url }); + res.end(); + })); +}); diff --git a/ee/server/startup/index.ts b/ee/server/startup/index.ts new file mode 100644 index 00000000000..2a58ffbd4b4 --- /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 00000000000..a6eb60194b2 --- /dev/null +++ b/ee/server/startup/seatsCap.ts @@ -0,0 +1,109 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +import { callbacks } from '../../../app/callbacks/server'; +import { canAddNewUser, getMaxActiveUsers, isEnterprise, onValidateLicenses } from '../../app/license/server/license'; +import { createSeatsLimitBanners, disableDangerBannerDiscardingDismissal, disableWarningBannerDiscardingDismissal, enableDangerBanner, enableWarningBanner } 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'); + +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(); + } +}; + +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(() => { + createSeatsLimitBanners(); + + if (isEnterprise()) { + handleMaxSeatsBanners(); + } + + onValidateLicenses(handleMaxSeatsBanners); +}); diff --git a/package-lock.json b/package-lock.json index ffbee6ed687..8e4b082917d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -746,12 +746,6 @@ "node-releases": "^1.1.71" } }, - "caniuse-lite": { - "version": "1.0.30001237", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz", - "integrity": "sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw==", - "dev": true - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -3586,9 +3580,9 @@ } }, "@discoveryjs/json-ext": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.5.tgz", - "integrity": "sha512-6nFkfkmSeV/rqSaS4oWHgmpnYw194f6hmWF5is6b0J1naJZoiD0NTc9AiUwPHvWsowkjuHErCZT1wa0jg+BLIA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.3.tgz", + "integrity": "sha512-Fxt+AfXgjMoin2maPIYzFZnQjAXjAL0PHscM5pRTtatFqB+vZxAM9tLp2Optnuw3QOQC40jTNeGYFOMvyf7v9g==", "dev": true }, "@emotion/cache": { @@ -5301,19 +5295,37 @@ } }, "@rocket.chat/css-in-js": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.29.0.tgz", - "integrity": "sha512-4lZnRA8mkTp+x769b58hVyfpQSlSHVPlCp95Kk1DOd2b4T7oGlrenTFwUUtPhvDGYf5aI1n27aNkXMrFKfxdEQ==", + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.6.3-dev.322.tgz", + "integrity": "sha512-LzlPLlMfO/Brnl7oXe/dWezvsVqUAvu9N8KThp1e9ab0YbQzErOkn0KGzIxKgeFinjVPcdLjPRmawyFItK6yrg==", "requires": { "@emotion/hash": "^0.8.0", - "@rocket.chat/memo": "^0.29.0", + "@rocket.chat/css-supports": "^0.6.3-dev.322+b067cee6", + "@rocket.chat/memo": "^0.6.3-dev.322+b067cee6", + "@rocket.chat/stylis-logical-props-middleware": "^0.6.3-dev.322+b067cee6", "stylis": "^4.0.10" } }, + "@rocket.chat/css-supports": { + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/css-supports/-/css-supports-0.6.3-dev.322.tgz", + "integrity": "sha512-86hrV7ctnEu5ancYYkEWI4dS82MQEE5aI1Qln0Vnsc4NstDDvUsc9i5h6hy1RKqekRRpn+HhX0Q3Lh2/4K3wCA==", + "requires": { + "@rocket.chat/memo": "^0.6.3-dev.322+b067cee6", + "tslib": "^2.3.1" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@rocket.chat/emitter": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/emitter/-/emitter-0.29.0.tgz", - "integrity": "sha512-HBZnNFiIHQL0Lr5vIPew8WYQ8ztwZQVb+yKZqyBcmK71fWgYPW5SPMd9PaZNkue4D7Sp3SR57kKatmFtD7sKBQ==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/emitter/-/emitter-0.6.3-dev.322.tgz", + "integrity": "sha512-ytprj6pgVdHdxL3dpmeJq/IUd3cLpvaF/OV1uVJ3XtRYqvVnMtqfDXS41AoTlAVFYsD9swqrZBIigxdD4Q9t5g==" }, "@rocket.chat/eslint-config": { "version": "0.4.0", @@ -5325,26 +5337,27 @@ } }, "@rocket.chat/fuselage": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.29.0.tgz", - "integrity": "sha512-K9e501ovUvH0GRRdGfc113hYyUrBJBMLwUZrfIGbiA+gNc1irUNrkupLwwiJKjThIBzC9qWBmgVCAycUB5S7jw==", - "requires": { - "@rocket.chat/css-in-js": "^0.29.0", - "@rocket.chat/fuselage-tokens": "^0.29.0", - "@rocket.chat/memo": "^0.29.0", + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.6.3-dev.322.tgz", + "integrity": "sha512-+0zDTEfSuPDZuwq6T/oxC5klWyEO25U3FgfEDzWIDQpKk74LAMy2ULawQd1O37r7emvg8SPvxy7S9vyQNHnGsg==", + "requires": { + "@rocket.chat/css-in-js": "^0.6.3-dev.322+b067cee6", + "@rocket.chat/css-supports": "^0.6.3-dev.322+b067cee6", + "@rocket.chat/fuselage-tokens": "^0.6.3-dev.322+b067cee6", + "@rocket.chat/memo": "^0.6.3-dev.322+b067cee6", "invariant": "^2.2.4", "react-keyed-flatten-children": "^1.3.0" } }, "@rocket.chat/fuselage-hooks": { - "version": "0.6.3-dev.326", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.6.3-dev.326.tgz", - "integrity": "sha512-pSeMIJ563OnnjP9tRep6HNi527bSjX2LLt6Z0y8DPL17sVabF46gplP2eoEVuZgcEVF5PrYWo0bqEzosL1cOvQ==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.6.3-dev.322.tgz", + "integrity": "sha512-J2rsvhFPo/by/y2/z6uBTmDuOqAiocXODsYuFUVp4AyNW4LmZ/KD7o9MfPwlwGjlkbU9889JOabhcvgZ3qLkxQ==" }, "@rocket.chat/fuselage-polyfills": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.29.0.tgz", - "integrity": "sha512-a1XrcupzEnMTAEj5aHcsu97OIJVoWItQOIZd4s5zxrWLUEY30d/fDvS12pdCB0Yx26jXrUNAgtjsSC/XS/vR7Q==", + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.6.3-dev.322.tgz", + "integrity": "sha512-8i1i5087FK1t1+ifwLu5lu4LNz370oDhXDKtHL13vYcowE8FVCME12P9rnP349dFmSPQ2ZhpNyDSu9kaUGw6nA==", "requires": { "@juggle/resize-observer": "^3.3.1", "clipboard-polyfill": "^3.0.3", @@ -5355,19 +5368,19 @@ } }, "@rocket.chat/fuselage-tokens": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.29.0.tgz", - "integrity": "sha512-H2onK1pEF9SNd0xOc6zffZaSMnQMbQrfYB8LL0hpaX9fqlZ5xPEwoK7Qzty9ClSHq1AWPaxsOZaSxinoi+lz+w==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.6.3-dev.322.tgz", + "integrity": "sha512-FDbIEUeANKlZ/X6dVNwZwBHphVoTJRnHuYQUH1Uc65LX4FQe2BlD0dElp6f00z4SGmtSMDuyEWwwiEf2Z61eEA==" }, "@rocket.chat/fuselage-ui-kit": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.29.0.tgz", - "integrity": "sha512-rNmJvich3pPUiDxRXAuw2Q6hEEndWGHp7TpQiz+MeDMdHn1DbFCgAF/hFAQlFAniDOP1+lOlCGhu/snj9pSqhw==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.6.3-dev.322.tgz", + "integrity": "sha512-CVB0IRtpmc94fjQsMEIg6TkC2KX4WXBQ1ZZrOxgmuIcj4HQG/gBVah0B1O3ApQvORDijtJxogGE1oTXoLXrf0A==" }, "@rocket.chat/icons": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.29.0.tgz", - "integrity": "sha512-wDt1q1TbNCBoswKwBLv9/WIFHpgbiXgUk054Vx88sSni97rwHiSqod2fWmfNoaw9kK7NeMdUB08JklEJS4dGFQ==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.6.3-dev.322.tgz", + "integrity": "sha512-Ew5dIfoOovi/2cZxUboAD6jIr5dsPEwGDKBOxp9qVdP5VeJ+xzniLYK7XGh+4Lv98QIeT3Toi6en/RPcz9c9+g==" }, "@rocket.chat/livechat": { "version": "1.9.5", @@ -5422,9 +5435,9 @@ } }, "@rocket.chat/memo": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/memo/-/memo-0.29.0.tgz", - "integrity": "sha512-zgav/BgHdIyv0GeM5Zp+GluSww3SEzw0QALHv0uGmNr6LSjLT72xwc6lWMPfFeKTmsrI1WQM6xHwb8JCN8TKOQ==", + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/memo/-/memo-0.6.3-dev.322.tgz", + "integrity": "sha512-kcedVPwL0tvJaCtJFzDSTjibDw5MMEnvgwLxVykG8zcbjouVtGIL/sKqnee3Z0nqKBGWrvK4IchuPfC0ZYjjlw==", "requires": { "tslib": "^2.3.0" }, @@ -5437,9 +5450,9 @@ } }, "@rocket.chat/message-parser": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/message-parser/-/message-parser-0.29.0.tgz", - "integrity": "sha512-2qEPTNSHeWRCFfxCETqz9Asju6bf5dR98+AQUYwNus5911WW4tuehvrfvoqLYrlYqs4w5Qj6q9m2THCXqN27Gw==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/message-parser/-/message-parser-0.6.3-dev.322.tgz", + "integrity": "sha512-+0jSX9LIbvVkNuRHZruuW58m7yWyQ+GKiHW9k9maF0/pewDnD48YwxhB51u4eqmqhq6Yqmma36M2ucq5Twd3KA==" }, "@rocket.chat/mp3-encoder": { "version": "0.24.0", @@ -5492,9 +5505,9 @@ } }, "@rocket.chat/string-helpers": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/string-helpers/-/string-helpers-0.29.0.tgz", - "integrity": "sha512-3cpS4rabgMopg24f0heq9R+o21h6+wRWWgnIOFsO9yd2ZmAWZW6Vt7eKVmVDWjoNL1j0sDP12ogVPHlCnQarVA==", + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/string-helpers/-/string-helpers-0.6.3-dev.322.tgz", + "integrity": "sha512-Xk4Zzc/WgBwM9aDIQF2pWD71VrbzYfNGzvjx6Ws19eZQ5c4qQT19wu/lLvHvwop1pjwL0AwUO+Xg8hKTJG+WZA==", "requires": { "tslib": "^2.3.0" }, @@ -5506,10 +5519,26 @@ } } }, + "@rocket.chat/stylis-logical-props-middleware": { + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/stylis-logical-props-middleware/-/stylis-logical-props-middleware-0.6.3-dev.322.tgz", + "integrity": "sha512-Lv4+nKJ75oWh4nj8vZkV+wZkBTjpsmiuR4YhQuW2PJG+LPoXrQkS3BbhB8B7EZKabsPHiyUWeqz5tvcKj5aqoA==", + "requires": { + "@rocket.chat/css-supports": "^0.6.3-dev.322+b067cee6", + "tslib": "^2.2.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@rocket.chat/ui-kit": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.29.0.tgz", - "integrity": "sha512-a3NbyxPe4GlL3jSP1hhGwGPTeU6Fep57l2420SPnljvudgdb2BsBWVE1y5bp64fVYxW62a6b1YY1fVEtXF6EoA==" + "version": "0.6.3-dev.322", + "resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.6.3-dev.322.tgz", + "integrity": "sha512-O+1X2keDPnCR1LZztaRtWRPb9h8D8OSqav+pdnhJ3dEQ7i45x8rbwG+SntTl2rW9RIHFZgu2Z9pnoD9W1hy6Pg==" }, "@samverschueren/stream-to-observable": { "version": "0.3.1", @@ -8829,14 +8858,6 @@ "lodash.debounce": "^4.0.8", "resolve": "^1.14.2", "semver": "^6.1.2" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } } }, "@babel/highlight": { @@ -9023,45 +9044,6 @@ "webpack-filter-warnings-plugin": "^1.2.1", "webpack-hot-middleware": "^2.25.0", "webpack-virtual-modules": "^0.2.2" - }, - "dependencies": { - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - } } }, "@storybook/channel-postmessage": { @@ -9277,16 +9259,6 @@ "resolve": "^1.19.0" } }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, "fork-ts-checker-webpack-plugin": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.3.3.tgz", @@ -9323,33 +9295,6 @@ } } }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", @@ -9477,45 +9422,6 @@ "webpack": "4", "webpack-dev-middleware": "^3.7.3", "webpack-virtual-modules": "^0.2.2" - }, - "dependencies": { - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - } } }, "@storybook/node-logger": { @@ -9549,16 +9455,6 @@ "ts-dedent": "^2.0.0" } }, - "@storybook/semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", - "dev": true, - "requires": { - "core-js": "^3.6.5", - "find-up": "^4.1.0" - } - }, "@storybook/theming": { "version": "6.3.8", "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.3.8.tgz", @@ -9617,9 +9513,9 @@ } }, "@types/node": { - "version": "14.17.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.18.tgz", - "integrity": "sha512-haYyibw4pbteEhkSg0xdDLAI3679L75EJ799ymVrPxOA922bPx3ML59SoDsQ//rHlvqpu+e36kcbR3XRQtFblA==", + "version": "14.17.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz", + "integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA==", "dev": true }, "ajv": { @@ -9635,9 +9531,9 @@ } }, "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "dev": true }, "ansi-styles": { @@ -9758,33 +9654,6 @@ "ms": "2.1.2" } }, - "detect-port": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.3.0.tgz", - "integrity": "sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==", - "dev": true, - "requires": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -9817,6 +9686,43 @@ "pkg-dir": "^4.1.0" }, "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -9829,12 +9735,12 @@ } }, "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "requires": { - "locate-path": "^5.0.0", + "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, @@ -9931,12 +9837,12 @@ } }, "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "requires": { - "p-locate": "^4.1.0" + "p-locate": "^5.0.0" } }, "lru-cache": { @@ -9955,14 +9861,6 @@ "dev": true, "requires": { "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } } }, "markdown-to-jsx": { @@ -9992,9 +9890,9 @@ "dev": true }, "minipass": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.5.tgz", - "integrity": "sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", + "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", "dev": true, "requires": { "yallist": "^4.0.0" @@ -10023,21 +9921,21 @@ "dev": true }, "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "requires": { - "p-try": "^2.0.0" + "yocto-queue": "^0.1.0" } }, "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "requires": { - "p-limit": "^2.2.0" + "p-limit": "^3.0.2" } }, "p-map": { @@ -10080,45 +9978,6 @@ "dev": true, "requires": { "find-up": "^5.0.0" - }, - "dependencies": { - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - } } }, "postcss": { @@ -10248,6 +10107,45 @@ "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } } }, "regenerator-runtime": { @@ -10266,6 +10164,12 @@ "path-parse": "^1.0.6" } }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, "serialize-javascript": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", @@ -10350,14 +10254,14 @@ } }, "terser": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.9.0.tgz", - "integrity": "sha512-h5hxa23sCdpzcye/7b8YqbE5OwKca/ni0RQz1uRX3tGh8haaGHqcuSqbGRybuAKNdntZ0mDgFNXPJ48xQ2RXKQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.7.2.tgz", + "integrity": "sha512-0Omye+RD4X7X69O0eql3lC4Heh/5iLj3ggxR/B5ketZLOtLiOqukUgjw3q4PDnNQbsrkKr3UMypqStQG3XKRvw==", "dev": true, "requires": { "commander": "^2.20.0", "source-map": "~0.7.2", - "source-map-support": "~0.5.20" + "source-map-support": "~0.5.19" }, "dependencies": { "commander": { @@ -10397,15 +10301,6 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, "schema-utils": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", @@ -10642,6 +10537,61 @@ } } }, + "@storybook/semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", + "dev": true, + "requires": { + "core-js": "^3.6.5", + "find-up": "^4.1.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, "@storybook/source-loader": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.3.6.tgz", @@ -12366,9 +12316,9 @@ } }, "@xmldom/xmldom": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz", - "integrity": "sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==" + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.4.tgz", + "integrity": "sha512-wdxC79cvO7PjSM34jATd/RYZuYWQ8y/R7MidZl1NYYlbpFn1+spfjkiR3ZsJfcaTs2IyslBN7VwBBJwrYKM+zw==" }, "@xtuc/ieee754": { "version": "1.2.0", @@ -15972,14 +15922,6 @@ "electron-to-chromium": "^1.3.723", "escalade": "^3.1.1", "node-releases": "^1.1.71" - }, - "dependencies": { - "caniuse-lite": { - "version": "1.0.30001237", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz", - "integrity": "sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw==", - "dev": true - } } }, "bs58": { @@ -16010,9 +15952,9 @@ } }, "bson": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.5.2.tgz", - "integrity": "sha512-8CEMJpwc7qlQtrn2rney38jQSEeMar847lz0LyitwRmVknAW8iHXrzW4fTjHfyWm0E3sukyD/zppdH+QU1QefA==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.5.1.tgz", + "integrity": "sha512-XqFP74pbTVLyLy5KFxVfTUyRrC1mgOlmu/iXHfXqfCKT59jyP9lwbotGfbN59cHBRbJSamZNkrSopjv+N0SqAA==", "requires": { "buffer": "^5.6.0" } @@ -16322,9 +16264,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001207", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001207.tgz", - "integrity": "sha512-UPQZdmAsyp2qfCTiMU/zqGSWOYaY9F9LL61V8f+8MrubsaDGpaHD9HRV/EWZGULZn0Hxu48SKzI5DgFwTvHuYw==" + "version": "1.0.30001257", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001257.tgz", + "integrity": "sha512-JN49KplOgHSXpIsVSF+LUyhD8PUp6xPpAXeRrrcBh4KBeP7W864jHn6RvzJgDlrReyeVjMFJL3PLpPvKIxlIHA==" }, "capital-case": { "version": "1.0.4", @@ -16702,9 +16644,9 @@ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" }, "nth-check": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", - "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz", + "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==", "requires": { "boolbase": "^1.0.0" } @@ -18632,6 +18574,16 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" }, + "detect-port": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.3.0.tgz", + "integrity": "sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==", + "dev": true, + "requires": { + "address": "^1.0.1", + "debug": "^2.6.0" + } + }, "detect-port-alt": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", @@ -20704,9 +20656,9 @@ "integrity": "sha512-YN+CYfCVRVMUZOUPeinHNKgytM1wPI/C/UCLEi56EsY2dwwvI00kIJHJoI7pMVqGoMew8SMZ2SSfHKHULHXDsg==" }, "fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", "dev": true }, "fast-text-encoding": { @@ -25990,13 +25942,6 @@ "electron-to-chromium": "^1.3.723", "escalade": "^3.1.1", "node-releases": "^1.1.71" - }, - "dependencies": { - "caniuse-lite": { - "version": "1.0.30001241", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001241.tgz", - "integrity": "sha512-1uoSZ1Pq1VpH0WerIMqwptXHNNGfdl7d1cJUFs80CwQ/lVzdhTvsFZCeNFslze7AjsQnb4C85tzclPa1VShbeQ==" - } } }, "chalk": { @@ -34716,9 +34661,9 @@ } }, "sonic-boom": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.2.3.tgz", - "integrity": "sha512-dm32bzlBchhXoJZe0yLY/kdYsHtXhZphidIcCzJib1aEjfciZyvHJ3NjA1zh6jJCO/OBLfdjc5iw6jLS/Go2fg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.3.0.tgz", + "integrity": "sha512-lEPaw654/4/rCJHz/TNzV4GIthqCq4inO+O3aFhbdOvR1bE+2//sVkcS+xlqPdb8gdjQCEE0hE9BuvnVixbnWQ==", "requires": { "atomic-sleep": "^1.0.0" } diff --git a/package.json b/package.json index 405e7e33e28..acabd61fc90 100644 --- a/package.json +++ b/package.json @@ -160,19 +160,19 @@ "@nivo/line": "0.62.0", "@nivo/pie": "0.73.0", "@rocket.chat/apps-engine": "1.28.0-alpha.5379", - "@rocket.chat/css-in-js": "^0.29.0", - "@rocket.chat/emitter": "^0.29.0", - "@rocket.chat/fuselage": "^0.29.0", + "@rocket.chat/css-in-js": "^0.6.3-dev.322", + "@rocket.chat/emitter": "^0.6.3-dev.322", + "@rocket.chat/fuselage": "^0.6.3-dev.322", "@rocket.chat/fuselage-hooks": "^0.6.3-dev.322", - "@rocket.chat/fuselage-polyfills": "^0.29.0", - "@rocket.chat/fuselage-tokens": "^0.29.0", - "@rocket.chat/fuselage-ui-kit": "^0.29.0", - "@rocket.chat/icons": "^0.29.0", - "@rocket.chat/memo": "^0.29.0", - "@rocket.chat/message-parser": "^0.29.0", + "@rocket.chat/fuselage-polyfills": "^0.6.3-dev.322", + "@rocket.chat/fuselage-tokens": "^0.6.3-dev.322", + "@rocket.chat/fuselage-ui-kit": "^0.6.3-dev.322", + "@rocket.chat/icons": "^0.6.3-dev.322", + "@rocket.chat/memo": "^0.6.3-dev.322", + "@rocket.chat/message-parser": "^0.6.3-dev.322", "@rocket.chat/mp3-encoder": "^0.24.0", - "@rocket.chat/string-helpers": "^0.29.0", - "@rocket.chat/ui-kit": "^0.29.0", + "@rocket.chat/string-helpers": "^0.6.3-dev.322", + "@rocket.chat/ui-kit": "^0.6.3-dev.322", "@slack/client": "^4.12.0", "@types/lodash": "^4.14.171", "adm-zip": "0.4.14", @@ -252,7 +252,7 @@ "node-gcm": "1.0.0", "node-rsa": "^1.1.1", "nodemailer": "^6.6.2", - "object-path": "^0.11.6", + "object-path": "^0.11.8", "pdfjs-dist": "^2.8.335", "photoswipe": "^4.1.3", "pino": "^7.0.0-rc.6", diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 581953f4f1e..a5c41b41ffb 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -868,6 +868,8 @@ "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", "close-others-livechat-room": "Close other Omnichannel Room", @@ -950,6 +952,7 @@ "Confirm_password": "Confirm your password", "Confirmation": "Confirmation", "Connect": "Connect", + "Connected": "Connected", "Connect_SSL_TLS": "Connect with SSL/TLS", "Connection_Closed": "Connection closed", "Connection_Reset": "Connection reset", @@ -1238,6 +1241,7 @@ "Create_channel": "Create Channel", "Create_A_New_Channel": "Create a New Channel", "Create_new": "Create new", + "Create_new_members": "Create New Members", "Create_unique_rules_for_this_channel": "Create unique rules for this channel", "create-c": "Create Public Channels", "create-c_description": "Permission to create public channels", @@ -1380,6 +1384,7 @@ "Department_removed": "Department removed", "Departments": "Departments", "Deployment_ID": "Deployment ID", + "Deployment": "Deployment", "Description": "Description", "Desktop": "Desktop", "Desktop_Notification_Test": "Desktop Notification Test", @@ -1712,6 +1717,7 @@ "error-invalid-username": "Invalid username", "error-invalid-value": "Invalid value", "error-invalid-webhook-response": "The webhook URL responded with a status other than 200", + "error-license-user-limit-reached": "The maximum number of users has been reached.", "error-logged-user-not-in-room": "You are not in the room `%s`", "error-max-guests-number-reached": "You reached the maximum number of guest users allowed by your license. Contact sale@rocket.chat for a new license.", "error-max-number-simultaneous-chats-reached": "The maximum number of simultaneous chats per agent has been reached.", @@ -2029,6 +2035,7 @@ "get-password-policy-mustContainAtLeastOneSpecialCharacter": "The password should contain at least one special character", "get-password-policy-mustContainAtLeastOneUppercase": "The password should contain at least one uppercase letter", "github_no_public_email": "You don't have any email as public email in your GitHub account", + "github_HEAD": "HEAD", "Give_a_unique_name_for_the_custom_oauth": "Give a unique name for the custom oauth", "Give_the_application_a_name_This_will_be_seen_by_your_users": "Give the application a name. This will be seen by your users.", "Global": "Global", @@ -2279,6 +2286,7 @@ "Invitation_HTML_Default": "

You have been invited to [Site_Name]

Go to [Site_URL] and try the best open source chat solution available today!

", "Invitation_Subject": "Invitation Subject", "Invitation_Subject_Default": "You have been invited to [Site_Name]", + "Invite": "Invite", "Invite_Link": "Invite Link", "Invite_user_to_join_channel": "Invite one user to join this channel", "Invite_user_to_join_channel_all_from": "Invite all users from [#channel] to join this channel", @@ -2657,6 +2665,7 @@ "Load_Balancing": "Load Balancing", "Load_more": "Load more", "Load_Rotation": "Load Rotation", + "Loading": "Loading", "Loading_more_from_history": "Loading more from history", "Loading_suggestion": "Loading suggestions", "Loading...": "Loading...", @@ -2947,6 +2956,7 @@ "Monday": "Monday", "Mongo_storageEngine": "Mongo Storage Engine", "Mongo_version": "Mongo Version", + "MongoDB": "MongoDB", "MongoDB_Deprecated": "MongoDB Deprecated", "MongoDB_version_s_is_deprecated_please_upgrade_your_installation": "MongoDB version %s is deprecated, please upgrade your installation.", "Monitor_added": "Monitor Added", @@ -3194,6 +3204,7 @@ "Others": "Others", "OTR": "OTR", "OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online", + "Out_of_seats": "Out of Seats", "Outgoing_WebHook": "Outgoing WebHook", "Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.", "Output_format": "Output format", @@ -3229,6 +3240,7 @@ "Phone": "Phone", "Phone_already_exists": "Phone already exists", "Phone_number": "Phone number", + "PID": "PID", "Pin": "Pin", "Pin_Message": "Pin Message", "pin-message": "Pin Message", @@ -3367,6 +3379,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", @@ -3450,6 +3463,11 @@ "Report_this_message_question_mark": "Report this message?", "Reporting": "Reporting", "Request": "Request", + "Request_seats": "Request Seats", + "Request_more_seats": "Request more seats.", + "Request_more_seats_out_of_seats": "You can not add members because this Workspace is out of seats, please request more seats.", + "Request_more_seats_sales_team": "Once your request is submitted, our Sales Team will look into it and will reach out to you within the next couple of days.", + "Request_more_seats_title": "Request More Seats", "Request_comment_when_closing_conversation": "Request comment when closing conversation", "Request_comment_when_closing_conversation_description": "If enabled, the agent will need to set a comment before the conversation is closed.", "Request_tag_before_closing_chat": "Request tag(s) before closing conversation", @@ -3680,6 +3698,8 @@ "Search_Provider": "Search Provider", "Search_Rooms": "Search Rooms", "Search_Users": "Search Users", + "Seats_Available": "__seatsLeft__ Seats Available", + "Seats_usage": "Seats Usage", "seconds": "seconds", "Secret_token": "Secret Token", "Security": "Security", @@ -4288,6 +4308,7 @@ "Update_your_RocketChat": "Update your Rocket.Chat", "Updated_at": "Updated at", "Upload": "Upload", + "Uploads": "Uploads", "Upload_app": "Upload App", "Upload_file_description": "File description", "Upload_file_name": "File name", @@ -4302,6 +4323,7 @@ "URL_room_hash_description": "Recommended to enable if the Jitsi instance doesn't use any authentication mechanism.", "URL_room_prefix": "URL room prefix", "URL_room_suffix": "URL room suffix", + "Usage": "Usage", "Use": "Use", "Use_account_preference": "Use account preference", "Use_Emojis": "Use Emojis", diff --git a/server/methods/deleteUser.js b/server/methods/deleteUser.js index e2c169167ff..f47da58045e 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/methods/registerUser.js b/server/methods/registerUser.js index d5cde1d372a..cef1d1b8398 100644 --- a/server/methods/registerUser.js +++ b/server/methods/registerUser.js @@ -62,14 +62,23 @@ Meteor.methods({ reason: formData.reason, }; - // Check if user has already been imported and never logged in. If so, set password and let it through - const importedUser = Users.findOneByEmailAddress(formData.email); let userId; - if (importedUser && importedUser.importIds && importedUser.importIds.length && !importedUser.lastLogin) { - Accounts.setPassword(importedUser._id, userData.password); - userId = importedUser._id; - } else { - userId = Accounts.createUser(userData); + try { + // Check if user has already been imported and never logged in. If so, set password and let it through + const importedUser = Users.findOneByEmailAddress(formData.email); + + if (importedUser && importedUser.importIds && importedUser.importIds.length && !importedUser.lastLogin) { + Accounts.setPassword(importedUser._id, userData.password); + userId = importedUser._id; + } else { + userId = Accounts.createUser(userData); + } + } catch (e) { + if (e instanceof Meteor.Error) { + throw e; + } + + throw new Meteor.Error(e.message); } Users.setName(userId, s.trim(formData.name)); diff --git a/server/methods/setUserActiveStatus.js b/server/methods/setUserActiveStatus.js index adecec84054..e094ec4e018 100644 --- a/server/methods/setUserActiveStatus.js +++ b/server/methods/setUserActiveStatus.js @@ -5,7 +5,7 @@ import { hasPermission } from '../../app/authorization'; import { setUserActiveStatus } from '../../app/lib/server/functions/setUserActiveStatus'; Meteor.methods({ - setUserActiveStatus(userId, active, confirmRelenquish) { + setUserActiveStatus(userId, active, confirmRelinquish) { check(userId, String); check(active, Boolean); @@ -21,7 +21,7 @@ Meteor.methods({ }); } - setUserActiveStatus(userId, active, confirmRelenquish); + setUserActiveStatus(userId, active, confirmRelinquish); return true; }, diff --git a/server/modules/listeners/listeners.module.ts b/server/modules/listeners/listeners.module.ts index 81ed742940f..f9ef52f434f 100644 --- a/server/modules/listeners/listeners.module.ts +++ b/server/modules/listeners/listeners.module.ts @@ -257,7 +257,14 @@ export class ListenersModule { }); service.onEvent('banner.new', (bannerId): void => { - notifications.notifyLoggedInThisInstance('new-banner', { bannerId }); + notifications.notifyLoggedInThisInstance('new-banner', { bannerId }); // deprecated + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); + }); + service.onEvent('banner.disabled', (bannerId): void => { + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); + }); + service.onEvent('banner.enabled', (bannerId): void => { + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); }); } } diff --git a/server/sdk/index.ts b/server/sdk/index.ts index 298caae6e5e..97d31b1467a 100644 --- a/server/sdk/index.ts +++ b/server/sdk/index.ts @@ -14,6 +14,7 @@ import { INPSService } from './types/INPSService'; import { ITeamService } from './types/ITeamService'; import { IRoomService } from './types/IRoomService'; import { IMediaService } from './types/IMediaService'; +import { IAnalyticsService } from './types/IAnalyticsService'; import { ILDAPService } from './types/ILDAPService'; // TODO think in a way to not have to pass the service name to proxify here as well @@ -28,6 +29,7 @@ export const NPS = proxifyWithWait('nps'); export const Team = proxifyWithWait('team'); export const Room = proxifyWithWait('room'); export const Media = proxifyWithWait('media'); +export const Analytics = proxifyWithWait('analytics'); export const LDAP = proxifyWithWait('ldap'); // Calls without wait. Means that the service is optional and the result may be an error diff --git a/server/sdk/lib/Events.ts b/server/sdk/lib/Events.ts index 3821e242d5c..0316f3bee4c 100644 --- a/server/sdk/lib/Events.ts +++ b/server/sdk/lib/Events.ts @@ -20,6 +20,8 @@ type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; export type EventSignatures = { 'banner.new'(bannerId: string): void; + 'banner.enabled'(bannerId: string): void; + 'banner.disabled'(bannerId: string): void; 'emoji.deleteCustom'(emoji: IEmoji): void; 'emoji.updateCustom'(emoji: IEmoji): void; 'license.module'(data: { module: string; valid: boolean }): void; diff --git a/server/sdk/types/IAnalyticsService.ts b/server/sdk/types/IAnalyticsService.ts new file mode 100644 index 00000000000..b23ba14c8ad --- /dev/null +++ b/server/sdk/types/IAnalyticsService.ts @@ -0,0 +1,7 @@ +import { IServiceClass } from './ServiceClass'; + +export interface IAnalyticsService extends IServiceClass { + saveSeatRequest(): Promise; + getSeatRequestCount(): Promise; + resetSeatRequestCount(): Promise; +} diff --git a/server/sdk/types/IBannerService.ts b/server/sdk/types/IBannerService.ts index 44bcc7ce259..2779f6fbfbe 100644 --- a/server/sdk/types/IBannerService.ts +++ b/server/sdk/types/IBannerService.ts @@ -1,7 +1,12 @@ import { BannerPlatform, IBanner } from '../../../definition/IBanner'; +import { Optional } from '../../../definition/utils'; export interface IBannerService { - getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise; - create(banner: Omit): Promise; + 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>): Promise; } diff --git a/server/services/analytics/service.ts b/server/services/analytics/service.ts new file mode 100644 index 00000000000..b2d4b8dee9e --- /dev/null +++ b/server/services/analytics/service.ts @@ -0,0 +1,29 @@ +import { Db } from 'mongodb'; + +import { ServiceClass } from '../../sdk/types/ServiceClass'; +import { IAnalyticsService } from '../../sdk/types/IAnalyticsService'; +import { AnalyticsRaw } from '../../../app/models/server/raw/Analytics'; + +export class AnalyticsService extends ServiceClass implements IAnalyticsService { + protected name = 'analytics'; + + private Analytics: AnalyticsRaw + + constructor(db: Db) { + super(); + this.Analytics = new AnalyticsRaw(db.collection('rocketchat_analytics')); + } + + async saveSeatRequest(): Promise { + this.Analytics.update({ type: 'seat-request' }, { $inc: { count: 1 } }, { upsert: true }); + } + + async getSeatRequestCount(): Promise { + const result = await this.Analytics.findOne({ type: 'seat-request' }); + return result ? result.count : 0; + } + + async resetSeatRequestCount(): Promise { + await this.Analytics.update({ type: 'seat-request' }, { $set: { count: 0 } }, { upsert: true }); + } +} diff --git a/server/services/banner/service.ts b/server/services/banner/service.ts index 83125612641..bcd3de4b9e3 100644 --- a/server/services/banner/service.ts +++ b/server/services/banner/service.ts @@ -9,6 +9,7 @@ import { IBannerService } from '../../sdk/types/IBannerService'; import { BannerPlatform, IBanner, IBannerDismiss } from '../../../definition/IBanner'; import { api } from '../../sdk/api'; import { IUser } from '../../../definition/IUser'; +import { Optional } from '../../../definition/utils'; export class BannerService extends ServiceClass implements IBannerService { protected name = 'banner'; @@ -27,26 +28,32 @@ export class BannerService extends ServiceClass implements IBannerService { this.Users = new UsersRaw(db.collection('users')); } - async create(doc: Omit): Promise { - const bannerId = uuidv4(); + async getById(bannerId: string): Promise { + return this.Banners.findOneById(bannerId); + } - doc.view.appId = 'banner-core'; - doc.view.viewId = bannerId; + async discardDismissal(bannerId: string): Promise { + const result = await this.Banners.findOneById(bannerId); - const invalidPlatform = doc.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform)); - if (invalidPlatform) { - throw new Error('Invalid platform'); + if (!result) { + return false; } - if (doc.startAt > doc.expireAt) { - throw new Error('Start date cannot be later than expire date'); - } + const { _id, ...banner } = result; - if (doc.expireAt < new Date()) { - throw new Error('Cannot create banner already expired'); - } + 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(); + + doc.view.appId = 'banner-core'; + doc.view.viewId = bannerId; - await this.Banners.insertOne({ + await this.Banners.create({ ...doc, _id: bannerId, }); @@ -61,7 +68,7 @@ export class BannerService extends ServiceClass implements IBannerService { return banner; } - async getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise { + async getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise { const user = await this.Users.findOneById>(userId, { projection: { roles: 1 } }); const { roles } = user || { roles: [] }; @@ -111,4 +118,29 @@ export class BannerService extends ServiceClass implements IBannerService { return true; } + + async disable(bannerId: string): Promise { + const result = await this.Banners.disable(bannerId); + + if (result) { + api.broadcast('banner.disabled', bannerId); + return true; + } + return false; + } + + async enable(bannerId: string, doc: Partial> = {}): Promise { + const result = await this.Banners.findOneById(bannerId); + + if (!result) { + return false; + } + + const { _id, ...banner } = result; + + this.Banners.update({ _id }, { ...banner, ...doc, active: true }); // reenable the banner + + api.broadcast('banner.enabled', bannerId); + return true; + } } diff --git a/server/services/startup.ts b/server/services/startup.ts index bee2dd61df0..2424b30dbb4 100644 --- a/server/services/startup.ts +++ b/server/services/startup.ts @@ -9,6 +9,7 @@ import { RoomService } from './room/service'; import { TeamService } from './team/service'; import { UiKitCoreApp } from './uikit-core-app/service'; import { MediaService } from './image/service'; +import { AnalyticsService } from './analytics/service'; import { LDAPService } from './ldap/service'; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; @@ -21,4 +22,5 @@ api.registerService(new NPSService(db)); api.registerService(new RoomService(db)); api.registerService(new TeamService(db)); api.registerService(new MediaService()); +api.registerService(new AnalyticsService(db)); api.registerService(new LDAPService()); diff --git a/server/startup/migrations/v232.ts b/server/startup/migrations/v232.ts index abe4113fb94..d983422c2b0 100644 --- a/server/startup/migrations/v232.ts +++ b/server/startup/migrations/v232.ts @@ -26,7 +26,7 @@ Migrations.add({ const admins = Promise.await(Users.find({ roles: 'admin' }, {}).toArray()); - const banners = admins.map((a) => Promise.await(Banner.getNewBannersForUser(a._id, BannerPlatform.Web))).flat(); + const banners = admins.map((a) => Promise.await(Banner.getBannersForUser(a._id, BannerPlatform.Web))).flat(); const msg = 'Please notice that after the next release (4.0) advanced functionalities of LDAP, SAML, and Custom Oauth will be available only in Enterprise Edition and Gold plan. Check the official announcement for more info: https://go.rocket.chat/i/authentication-changes'; // @ts-ignore const authBanner = banners.find((b) => b.view.blocks[0].text.text === msg);