From 9a1a786cce698fef7b3fe79756c53f8fa62a3b2a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 6 Oct 2023 14:07:47 -0300 Subject: [PATCH 01/17] chore: license v3 invalidation (#30585) --- .../ee/server/startup/maxRoomsPerGuest.ts | 2 +- apps/meteor/ee/server/startup/seatsCap.ts | 2 +- .../license/__tests__/setLicense.spec.ts | 53 +++++- ee/packages/license/src/events/emitter.ts | 1 + ee/packages/license/src/index.ts | 3 +- ee/packages/license/src/license.spec.ts | 153 ++++++++++++++++++ ee/packages/license/src/license.ts | 120 +++++++------- ee/packages/license/src/tags.ts | 23 +-- .../src/validation/filterBehaviorsResult.ts | 4 + .../getCurrentValueForLicenseLimit.ts | 6 +- .../src/validation/validateLicenseLimits.ts | 21 ++- packages/rest-typings/src/v1/licenses.ts | 1 - 12 files changed, 308 insertions(+), 81 deletions(-) create mode 100644 ee/packages/license/src/validation/filterBehaviorsResult.ts diff --git a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts index 5731ca0d1deb..bfcb1ba5fa8b 100644 --- a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts +++ b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts @@ -8,7 +8,7 @@ callbacks.add( 'beforeAddedToRoom', async ({ user }) => { if (user.roles?.includes('guest')) { - if (await License.shouldPreventAction('roomsPerGuest', { userId: user._id })) { + if (await License.shouldPreventAction('roomsPerGuest', 0, { userId: user._id })) { throw new Meteor.Error('error-max-rooms-per-guest-reached', i18n.t('error-max-rooms-per-guest-reached')); } } diff --git a/apps/meteor/ee/server/startup/seatsCap.ts b/apps/meteor/ee/server/startup/seatsCap.ts index f6d42823cb97..e72852052acc 100644 --- a/apps/meteor/ee/server/startup/seatsCap.ts +++ b/apps/meteor/ee/server/startup/seatsCap.ts @@ -33,7 +33,7 @@ callbacks.add( callbacks.add( 'beforeUserImport', async ({ userCount }) => { - if (await License.shouldPreventAction('activeUsers', {}, userCount)) { + if (await License.shouldPreventAction('activeUsers', userCount)) { throw new Meteor.Error('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached')); } }, diff --git a/ee/packages/license/__tests__/setLicense.spec.ts b/ee/packages/license/__tests__/setLicense.spec.ts index 962f591750ad..35a7a495edc0 100644 --- a/ee/packages/license/__tests__/setLicense.spec.ts +++ b/ee/packages/license/__tests__/setLicense.spec.ts @@ -14,7 +14,7 @@ const VALID_LICENSE = describe('License set license procedures', () => { describe('Invalid formats', () => { - it('by default it should have no license', async () => { + it('should have no license by default', async () => { const license = new LicenseImp(); expect(license.hasValidLicense()).toBe(false); @@ -39,7 +39,7 @@ describe('License set license procedures', () => { await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(DuplicatedLicenseError); }); - it('should keep a valid license if a new invalid license is applied', async () => { + it('should keep a valid license if a new invalid formatted license is applied', async () => { const license = await getReadyLicenseManager(); await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true); @@ -99,5 +99,54 @@ describe('License set license procedures', () => { await expect(license.hasValidLicense()).toBe(true); await expect(license.hasModule('livechat-enterprise')).toBe(true); }); + + it('should call a validated event after set a valid license', async () => { + const license = await getReadyLicenseManager(); + const validateCallback = jest.fn(); + license.onValidateLicense(validateCallback); + await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true); + await expect(license.hasValidLicense()).toBe(true); + expect(validateCallback).toBeCalledTimes(1); + }); + + describe('License limits', () => { + describe('invalidate license', () => { + it('should trigger an invalidation event when a license with invalid limits is set after a valid one', async () => { + const invalidationCallback = jest.fn(); + + const licenseManager = await getReadyLicenseManager(); + const mocked = await new MockedLicenseBuilder(); + const oldToken = await mocked + .withLimits('activeUsers', [ + { + max: 10, + behavior: 'invalidate_license', + }, + ]) + .sign(); + + const newToken = await mocked + .withLimits('activeUsers', [ + { + max: 1, + behavior: 'invalidate_license', + }, + ]) + .sign(); + + licenseManager.onInvalidateLicense(invalidationCallback); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.setLicense(oldToken)).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(true); + + await expect(licenseManager.setLicense(newToken)).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(false); + + await expect(invalidationCallback).toBeCalledTimes(1); + }); + }); + }); }); }); diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9258fb29444c..9256bcafe5f7 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -23,6 +23,7 @@ export function moduleRemoved(this: LicenseManager, module: LicenseModule) { export function behaviorTriggered(this: LicenseManager, options: BehaviorWithContext) { const { behavior, reason, modules: _, ...rest } = options; + try { this.emit(`behavior:${behavior}`, { reason, diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index fe5f3a84a12f..77e2976f156a 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -53,7 +53,6 @@ interface License { license: ILicenseV3 | undefined; activeModules: LicenseModule[]; limits: Record; - inFairPolicy: boolean; }>; // Deprecated: @@ -82,7 +81,7 @@ export class LicenseImp extends LicenseManager implements License { getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit; public async isLimitReached(action: T, context?: Partial>): Promise { - return this.shouldPreventAction(action, context, 0); + return this.shouldPreventAction(action, 0, context); } onValidFeature = onValidFeature; diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 36744585d59f..989be7b69ae1 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -40,3 +40,156 @@ it('should prevent if the counter is equal or over the limit', async () => { licenseManager.setLicenseLimitCounter('activeUsers', () => 11); await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); + +describe('Validate License Limits', () => { + describe('prevent_action behavior', () => { + describe('during the licensing apply', () => { + it('should not trigger the event even if the counter is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const preventActionCallback = jest.fn(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + expect(preventActionCallback).toHaveBeenCalledTimes(0); + }); + }); + }); + describe('fair usage behavior', () => { + it('should change the flag to true if the counter is equal or over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const fairUsageCallback = jest.fn(); + const preventActionCallback = jest.fn(); + + licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback); + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 10, + behavior: 'start_fair_policy', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + + preventActionCallback.mockClear(); + fairUsageCallback.mockClear(); + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(1); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + preventActionCallback.mockClear(); + fairUsageCallback.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventActionCallback).toHaveBeenCalledTimes(4); + expect(fairUsageCallback).toHaveBeenCalledTimes(4); + }); + }); + + describe('invalidate_license behavior', () => { + it('should invalidate the license if the counter is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const invalidateCallback = jest.fn(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 10, + behavior: 'invalidate_license', + }, + ]); + + licenseManager.on('invalidate', invalidateCallback); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.hasValidLicense()).toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.hasValidLicense()).toBe(true); + + await licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(true); + expect(invalidateCallback).toHaveBeenCalledTimes(0); + + await licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(false); + expect(invalidateCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('prevent action for future limits', () => { + it('should prevent if the counter plus the extra value is equal or over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + const fairUsageCallback = jest.fn(); + const preventActionCallback = jest.fn(); + + licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback); + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + + for await (const extraCount of [1, 2, 3, 4, 5]) { + await expect(licenseManager.shouldPreventAction('activeUsers', extraCount)).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + } + + /** + * if we are testing the current count 10 should prevent the action, if we are testing the future count 10 should not prevent the action but 11 + */ + + await expect(licenseManager.shouldPreventAction('activeUsers', 6)).resolves.toBe(true); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 4121c70267da..f2d8eef362bd 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -1,5 +1,6 @@ import { Emitter } from '@rocket.chat/emitter'; +import { type ILicenseTag } from './definition/ILicenseTag'; import type { ILicenseV2 } from './definition/ILicenseV2'; import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; import type { BehaviorWithContext } from './definition/LicenseBehavior'; @@ -18,6 +19,7 @@ import { showLicense } from './showLicense'; import { replaceTags } from './tags'; import { decrypt } from './token'; import { convertToV3 } from './v2/convertToV3'; +import { filterBehaviorsResult } from './validation/filterBehaviorsResult'; import { getCurrentValueForLicenseLimit } from './validation/getCurrentValueForLicenseLimit'; import { getModulesToDisable } from './validation/getModulesToDisable'; import { isBehaviorsInResult } from './validation/isBehaviorsInResult'; @@ -32,6 +34,8 @@ export class LicenseManager extends Emitter { pendingLicense = ''; + tags = new Set(); + modules = new Set(); private workspaceUrl: string | undefined; @@ -42,10 +46,14 @@ export class LicenseManager extends Emitter { private _valid: boolean | undefined; - private _inFairPolicy = false; - private _lockedLicense: string | undefined; + constructor() { + super(); + + this.on('validate', () => showLicense.call(this, this._license, this._valid)); + } + public get license(): ILicenseV3 | undefined { return this._license; } @@ -58,10 +66,6 @@ export class LicenseManager extends Emitter { return this._valid; } - public get inFairPolicy(): boolean { - return this._inFairPolicy; - } - public async setWorkspaceUrl(url: string) { this.workspaceUrl = url.replace(/\/$/, '').replace(/^https?:\/\/(.*)$/, '$1'); @@ -81,8 +85,8 @@ export class LicenseManager extends Emitter { try { await this.validateLicense({ ...options, isNewLicense: false }); - } finally { - if (!this.hasValidLicense()) { + } catch (e) { + if (e instanceof InvalidLicenseError) { this.invalidateLicense(); } } @@ -91,13 +95,13 @@ export class LicenseManager extends Emitter { private clearLicenseData(): void { this._license = undefined; this._unmodifiedLicense = undefined; - this._inFairPolicy = false; this._valid = false; this._lockedLicense = undefined; clearPendingLicense.call(this); } private invalidateLicense(): void { + this._valid = false; licenseInvalidated.call(this); invalidateAll.call(this); } @@ -110,16 +114,15 @@ export class LicenseManager extends Emitter { this._unmodifiedLicense = originalLicense || newLicense; this._license = newLicense; - await this.validateLicense({ isNewLicense: encryptedLicense !== this._lockedLicense }); + const isNewLicense = encryptedLicense !== this._lockedLicense; this._lockedLicense = encryptedLicense; - if (this.valid) { - licenseValidated.call(this); - showLicense.call(this, this._license, this._valid); - } - } finally { - if (hadValidLicense && !this.hasValidLicense()) { - this.invalidateLicense(); + await this.validateLicense({ isNewLicense }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + if (hadValidLicense) { + this.invalidateLicense(); + } } } } @@ -141,11 +144,40 @@ export class LicenseManager extends Emitter { throw new NotReadyForValidation(); } - const validationResult = await runValidation.call(this, this._license, options); - this.processValidationResult(validationResult, options); + const validationResult = await runValidation.call(this, this._license, { + behaviors: ['invalidate_license', 'start_fair_policy', 'prevent_installation', 'disable_modules'], + ...options, + }); + + if (isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation'])) { + throw new InvalidLicenseError(); + } + + const shouldLogModules = !this._valid || options.isNewLicense; + + this._valid = true; + + if (this._license.information.tags) { + replaceTags.call(this, this._license.information.tags); + } + + const disabledModules = getModulesToDisable(validationResult); + const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module)); + + const modulesChanged = replaceModules.call( + this, + modulesToEnable.map(({ module }) => module), + ); + + if (shouldLogModules || modulesChanged) { + logger.log({ msg: 'License validated', modules: modulesToEnable }); + } + if (!options.isNewLicense) { this.triggerBehaviorEvents(validationResult); } + + licenseValidated.call(this); } public async setLicense(encryptedLicense: string): Promise { @@ -192,36 +224,6 @@ export class LicenseManager extends Emitter { } } - private processValidationResult(result: BehaviorWithContext[], options: LicenseValidationOptions): void { - if (!this._license || isBehaviorsInResult(result, ['invalidate_license', 'prevent_installation'])) { - this._valid = false; - return; - } - - const shouldLogModules = !this._valid || options.isNewLicense; - - this._valid = true; - if (isBehaviorsInResult(result, ['start_fair_policy'])) { - this._inFairPolicy = true; - } - - if (this._license.information.tags) { - replaceTags(this._license.information.tags); - } - - const disabledModules = getModulesToDisable(result); - const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module)); - - const modulesChanged = replaceModules.call( - this, - modulesToEnable.map(({ module }) => module), - ); - - if (shouldLogModules || modulesChanged) { - logger.log({ msg: 'License validated', modules: modulesToEnable }); - } - } - private triggerBehaviorEvents(validationResult: BehaviorWithContext[]): void { for (const { ...options } of validationResult) { behaviorTriggered.call(this, { ...options }); @@ -240,8 +242,8 @@ export class LicenseManager extends Emitter { public async shouldPreventAction( action: T, + extraCount = 0, context: Partial> = {}, - newCount = 1, { suppressLog }: Pick = {}, ): Promise { const license = this.getLicense(); @@ -250,19 +252,29 @@ export class LicenseManager extends Emitter { } const options: LicenseValidationOptions = { - behaviors: ['prevent_action'], + ...(extraCount && { behaviors: ['prevent_action'] }), isNewLicense: false, suppressLog: !!suppressLog, context: { [action]: { - extraCount: newCount, + extraCount, ...context, }, }, }; const validationResult = await runValidation.call(this, license, options); - this.triggerBehaviorEvents(validationResult); + + // extra values should not call events since they are not actually reaching the limit just checking if they would + if (extraCount) { + return isBehaviorsInResult(validationResult, ['prevent_action']); + } + + if (isBehaviorsInResult(validationResult, ['invalidate_license', 'disable_modules', 'start_fair_policy'])) { + await this.revalidateLicense(); + } + + this.triggerBehaviorEvents(filterBehaviorsResult(validationResult, ['prevent_action'])); return isBehaviorsInResult(validationResult, ['prevent_action']); } @@ -271,7 +283,6 @@ export class LicenseManager extends Emitter { license: ILicenseV3 | undefined; activeModules: LicenseModule[]; limits: Record; - inFairPolicy: boolean; }> { const activeModules = getModules.call(this); const license = this.getLicense(); @@ -302,7 +313,6 @@ export class LicenseManager extends Emitter { license, activeModules, limits: limits as Record, - inFairPolicy: this.inFairPolicy, }; } } diff --git a/ee/packages/license/src/tags.ts b/ee/packages/license/src/tags.ts index ca2639678475..33434cae116d 100644 --- a/ee/packages/license/src/tags.ts +++ b/ee/packages/license/src/tags.ts @@ -1,23 +1,24 @@ import type { ILicenseTag } from './definition/ILicenseTag'; +import { type LicenseManager } from './license'; -export const tags = new Set(); - -export const addTag = (tag: ILicenseTag) => { +export function addTag(this: LicenseManager, tag: ILicenseTag) { // make sure to not add duplicated tag names - for (const addedTag of tags) { + for (const addedTag of this.tags) { if (addedTag.name.toLowerCase() === tag.name.toLowerCase()) { return; } } - tags.add(tag); -}; + this.tags.add(tag); +} -export const replaceTags = (newTags: ILicenseTag[]) => { - tags.clear(); +export function replaceTags(this: LicenseManager, newTags: ILicenseTag[]) { + this.tags.clear(); for (const tag of newTags) { - addTag(tag); + addTag.call(this, tag); } -}; +} -export const getTags = () => [...tags]; +export function getTags(this: LicenseManager) { + return [...this.tags]; +} diff --git a/ee/packages/license/src/validation/filterBehaviorsResult.ts b/ee/packages/license/src/validation/filterBehaviorsResult.ts new file mode 100644 index 000000000000..e51dbac20a53 --- /dev/null +++ b/ee/packages/license/src/validation/filterBehaviorsResult.ts @@ -0,0 +1,4 @@ +import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; + +export const filterBehaviorsResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) => + result.filter(({ behavior }) => expectedBehaviors.includes(behavior)); diff --git a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts index 60443cc408bb..8f9c6ed4034e 100644 --- a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts +++ b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts @@ -27,11 +27,7 @@ export async function getCurrentValueForLicenseLimit throw new Error('Unable to validate license limit due to missing data counter.'); } - const extraCount = context?.extraCount || 0; - - const count = await counterFn(context as LimitContext | undefined); - - return count + extraCount; + return counterFn(context as LimitContext | undefined); } export function hasAllDataCounters(this: LicenseManager) { diff --git a/ee/packages/license/src/validation/validateLicenseLimits.ts b/ee/packages/license/src/validation/validateLicenseLimits.ts index cd2674350946..f321252ba573 100644 --- a/ee/packages/license/src/validation/validateLicenseLimits.ts +++ b/ee/packages/license/src/validation/validateLicenseLimits.ts @@ -24,10 +24,26 @@ export async function validateLicenseLimits( return []; } - const currentValue = await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey]); + const extraCount = options.context?.[limitKey]?.extraCount ?? 0; + const currentValue = (await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey])) + extraCount; return limitList - .filter(({ max }) => max < currentValue) + .filter(({ max, behavior }) => { + switch (behavior) { + case 'invalidate_license': + case 'prevent_installation': + case 'disable_modules': + case 'start_fair_policy': + default: + return currentValue > max; + case 'prevent_action': + /** + * if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max + */ + + return extraCount ? currentValue > max : currentValue >= max; + } + }) .map((limit) => { if (!options.suppressLog) { logger.error({ @@ -36,7 +52,6 @@ export async function validateLicenseLimits( limit, }); } - return getResultingBehavior(limit, { reason: 'limit', limit: limitKey }); }); }), diff --git a/packages/rest-typings/src/v1/licenses.ts b/packages/rest-typings/src/v1/licenses.ts index 6dc935aae739..87c0106f6d3f 100644 --- a/packages/rest-typings/src/v1/licenses.ts +++ b/packages/rest-typings/src/v1/licenses.ts @@ -49,7 +49,6 @@ export type LicensesEndpoints = { license: ILicenseV3 | undefined; activeModules: string[]; limits: Record; - inFairPolicy: boolean; }; }; }; From 75f0ae31d9c6b6962762d8684e7d743395566188 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 6 Oct 2023 11:27:15 -0600 Subject: [PATCH 02/17] fix: Remove monitors query restrictions on update (#30550) --- .changeset/dull-trainers-drive.md | 5 ++++ .../app/livechat/server/lib/Livechat.js | 2 +- .../hooks/applyDepartmentRestrictions.ts | 9 +++--- .../server/hooks/applyRoomRestrictions.ts | 2 ++ .../server/methods/getUnitsFromUserRoles.ts | 30 ++++++++++++++----- .../ee/server/models/raw/LivechatRooms.ts | 27 ----------------- .../ee/server/models/raw/LivechatUnit.ts | 9 ------ .../meteor/server/models/raw/LivechatRooms.ts | 15 ---------- 8 files changed, 36 insertions(+), 63 deletions(-) create mode 100644 .changeset/dull-trainers-drive.md diff --git a/.changeset/dull-trainers-drive.md b/.changeset/dull-trainers-drive.md new file mode 100644 index 000000000000..f5a673cd8c30 --- /dev/null +++ b/.changeset/dull-trainers-drive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Remove model-level query restrictions for monitors diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index ffd3a29b229f..c560f3dd7aa7 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -285,7 +285,7 @@ export const Livechat = { Livechat.logger.debug(`Closing open chats for user ${userId}`); const user = await Users.findOneById(userId); - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}); + const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); const promises = []; await openChats.forEach((room) => { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts index 3c96cad39b72..d609d8464b04 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts @@ -4,16 +4,17 @@ import type { FilterOperators } from 'mongodb'; import { hasRoleAsync } from '../../../../../app/authorization/server/functions/hasRole'; import { callbacks } from '../../../../../lib/callbacks'; import { cbLogger } from '../lib/logger'; -import { getUnitsFromUser } from '../lib/units'; +import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; -export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators = {}) => { +export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators = {}, userId: string) => { const query: FilterOperators = { ...originalQuery, type: { $ne: 'u' } }; - const units = await getUnitsFromUser(); + const units = await getUnitsFromUser(userId); if (Array.isArray(units)) { query.ancestors = { $in: units }; } + cbLogger.debug({ msg: 'Applying department query restrictions', userId, units }); return query; }; @@ -25,7 +26,7 @@ callbacks.add( } cbLogger.debug('Applying department query restrictions'); - return addQueryRestrictionsToDepartmentsModel(originalQuery); + return addQueryRestrictionsToDepartmentsModel(originalQuery, userId); }, callbacks.priority.HIGH, 'livechat-apply-department-restrictions', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyRoomRestrictions.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyRoomRestrictions.ts index 1a18b92dc94d..597a7546e99a 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyRoomRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyRoomRestrictions.ts @@ -3,6 +3,7 @@ import { LivechatDepartment } from '@rocket.chat/models'; import type { FilterOperators } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; +import { cbLogger } from '../lib/logger'; import { getUnitsFromUser } from '../lib/units'; export const restrictQuery = async (originalQuery: FilterOperators = {}) => { @@ -20,6 +21,7 @@ export const restrictQuery = async (originalQuery: FilterOperators { +async function getUnitsFromUserRoles(user: string): Promise { + return LivechatUnit.findByMonitorId(user); +} + +async function getDepartmentsFromUserRoles(user: string): Promise { + return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId); +} + +const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: 10000 }); +const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: 5000 }); + +export const getUnitsFromUser = async (user: string): Promise => { if (!user || (await hasAnyRoleAsync(user, ['admin', 'livechat-manager']))) { return; } @@ -14,10 +26,11 @@ async function getUnitsFromUserRoles(user: string | null): Promise({ - 'livechat:getUnitsFromUser'(): Promise { + async 'livechat:getUnitsFromUser'(): Promise { const user = Meteor.userId(); - return memoizedGetUnitFromUserRoles(user); + if (!user) { + return; + } + return getUnitsFromUser(user); }, }); diff --git a/apps/meteor/ee/server/models/raw/LivechatRooms.ts b/apps/meteor/ee/server/models/raw/LivechatRooms.ts index b39e3d9eacfa..3295af1b6179 100644 --- a/apps/meteor/ee/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/ee/server/models/raw/LivechatRooms.ts @@ -11,7 +11,6 @@ import type { FindCursor, UpdateResult, Document, FindOptions, Db, Collection, F import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { LivechatRoomsRaw } from '../../../../server/models/raw/LivechatRooms'; -import { addQueryRestrictionsToRoomsModel } from '../../../app/livechat-enterprise/server/lib/query.helper'; declare module '@rocket.chat/model-typings' { interface ILivechatRoomsModel { @@ -296,32 +295,6 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo return this.updateOne(query, update); } - /** @deprecated Use updateOne or updateMany instead */ - async update(...args: Parameters) { - const [query, ...restArgs] = args; - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - return super.update(restrictedQuery, ...restArgs); - } - - async updateOne(...args: [...Parameters, { bypassUnits?: boolean }?]) { - const [query, update, opts, extraOpts] = args; - if (extraOpts?.bypassUnits) { - // When calling updateOne from a service, we cannot call the meteor code inside the query restrictions - // So the solution now is to pass a bypassUnits flag to the updateOne method which prevents checking - // units restrictions on the query, but just for the query the service is actually using - // We need to find a way of remove the meteor dependency when fetching units, and then, we can remove this flag - return super.updateOne(query, update, opts); - } - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - return super.updateOne(restrictedQuery, update, opts); - } - - async updateMany(...args: Parameters) { - const [query, ...restArgs] = args; - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - return super.updateMany(restrictedQuery, ...restArgs); - } - getConversationsBySource(start: Date, end: Date, extraQuery: Filter): AggregationCursor { return this.col.aggregate( [ diff --git a/apps/meteor/ee/server/models/raw/LivechatUnit.ts b/apps/meteor/ee/server/models/raw/LivechatUnit.ts index 180b145e4352..fcabf12fa4f8 100644 --- a/apps/meteor/ee/server/models/raw/LivechatUnit.ts +++ b/apps/meteor/ee/server/models/raw/LivechatUnit.ts @@ -51,15 +51,6 @@ export class LivechatUnitRaw extends BaseRaw implement return this.col.findOne(query, options); } - async update( - originalQuery: Filter, - update: Filter, - options: FindOptions, - ): Promise { - const query = await addQueryRestrictions(originalQuery); - return this.col.updateOne(query, update, options); - } - remove(query: Filter): Promise { return this.deleteMany(query); } diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index bf44a51b7f64..974c2b5cb570 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -1518,11 +1518,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $set: { pdfTranscriptRequested: true }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } @@ -1534,11 +1529,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $unset: { pdfTranscriptRequested: 1 }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } @@ -1550,11 +1540,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $set: { pdfTranscriptFileId: fileId }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } From df5c496487acd331e2c90e48d8a24a52a5bb8bee Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Fri, 6 Oct 2023 22:35:17 +0400 Subject: [PATCH 03/17] test: Improve tests for legacy omnichannel analytics (#30379) --- apps/meteor/tests/data/livechat/department.ts | 26 +- apps/meteor/tests/data/livechat/rooms.ts | 3 + apps/meteor/tests/data/livechat/users.ts | 23 +- apps/meteor/tests/data/livechat/utils.ts | 4 + .../end-to-end/api/livechat/04-dashboards.ts | 499 +++++++++++++++++- packages/random/src/NodeRandomGenerator.ts | 10 + 6 files changed, 523 insertions(+), 42 deletions(-) diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index e11324a47a46..8aba28addfcf 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -2,9 +2,9 @@ import { faker } from '@faker-js/faker'; import { expect } from 'chai'; import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; import { api, credentials, methodCall, request } from '../api-data'; -import { IUserCredentialsHeader, password } from '../user'; -import { login } from '../users.helper'; -import { createAgent, makeAgentAvailable } from './rooms'; +import { IUserCredentialsHeader } from '../user'; +import { createAnOnlineAgent } from './users'; +import { WithRequiredProperty } from './utils'; export const NewDepartmentData = ((): Partial => ({ enabled: true, @@ -59,29 +59,19 @@ new Promise((resolve, reject) => { export const createDepartmentWithAnOnlineAgent = async (): Promise<{department: ILivechatDepartment, agent: { credentials: IUserCredentialsHeader; - user: IUser; + user: WithRequiredProperty; }}> => { - // TODO moving here for tests - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - const { body } = await request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }); - const agent = body.user; - const createdUserCredentials = await login(agent.username, password); - await createAgent(agent.username); - await makeAgentAvailable(createdUserCredentials); + const { user, credentials } = await createAnOnlineAgent(); const department = await createDepartmentWithMethod() as ILivechatDepartment; - await addOrRemoveAgentFromDepartment(department._id, {agentId: agent._id, username: (agent.username as string)}, true); + await addOrRemoveAgentFromDepartment(department._id, {agentId: user._id, username: user.username}, true); return { department, agent: { - credentials: createdUserCredentials, - user: agent, + credentials, + user, } }; }; diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index c2658c73af8d..5efb279dcb18 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -185,6 +185,9 @@ export const getLivechatRoomInfo = (roomId: string): Promise = }); }; +/** + * @summary Sends message as visitor +*/ export const sendMessage = (roomId: string, message: string, visitorToken: string): Promise => { return new Promise((resolve, reject) => { request diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 7a5dc23b4cc0..38fb176faaa4 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import type { IUser } from "@rocket.chat/core-typings"; -import { password } from "../user"; +import { IUserCredentialsHeader, password } from "../user"; import { createUser, login } from "../users.helper"; import { createAgent, makeAgentAvailable } from "./rooms"; import { api, credentials, request } from "../api-data"; @@ -29,3 +29,24 @@ export const removeAgent = async (userId: string): Promise => { .set(credentials) .expect(200); } + +export const createAnOnlineAgent = async (): Promise<{ + credentials: IUserCredentialsHeader; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Date.now()}`; + const email = `${username}@rocket.chat`; + const { body } = await request + .post(api('users.create')) + .set(credentials) + .send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentAvailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +} diff --git a/apps/meteor/tests/data/livechat/utils.ts b/apps/meteor/tests/data/livechat/utils.ts index 89b6af709fbf..b6fd3a4bf6b3 100644 --- a/apps/meteor/tests/data/livechat/utils.ts +++ b/apps/meteor/tests/data/livechat/utils.ts @@ -1,6 +1,10 @@ export type DummyResponse = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; + export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index 61a2719d9cba..c12b875783ab 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -1,12 +1,30 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { before, describe, it } from 'mocha'; +import moment from 'moment'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { addOrRemoveAgentFromDepartment, createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { + closeOmnichannelRoom, + placeRoomOnHold, + sendAgentMessage, + sendMessage, + startANewLivechatRoomAndTakeIt, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent } from '../../../data/livechat/users'; +import { sleep } from '../../../data/livechat/utils'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting } from '../../../data/permissions.helper'; +import type { IUserCredentialsHeader } from '../../../data/user'; +import { IS_EE } from '../../../e2e/config/constants'; describe('LIVECHAT - dashboards', function () { this.retries(0); + // This test is expected to take more time since we're simulating real time conversations to verify analytics + this.timeout(60000); before((done) => getCredentials(done)); @@ -14,6 +32,106 @@ describe('LIVECHAT - dashboards', function () { await updateSetting('Livechat_enabled', true); }); + let department: ILivechatDepartment; + const agents: { + credentials: IUserCredentialsHeader; + user: IUser & { username: string }; + }[] = []; + let avgClosedRoomChatDuration = 0; + + const inactivityTimeout = 3; + + const TOTAL_MESSAGES = { + min: 5, + max: 10, + }; + const DELAY_BETWEEN_MESSAGES = { + min: 1000, + max: (inactivityTimeout - 1) * 1000, + }; + const TOTAL_ROOMS = 7; + + const simulateRealtimeConversation = async (chatInfo: Awaited>[]) => { + const promises = chatInfo.map(async (info) => { + const { room, visitor } = info; + + // send a few messages + const numberOfMessages = Random.between(TOTAL_MESSAGES.min, TOTAL_MESSAGES.max); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of Array(numberOfMessages - 1).keys()) { + // flip a coin to decide who will send the message + const willSendFromAgent = Random.between(0, 1) === 1; + + if (willSendFromAgent) { + await sendAgentMessage(room._id); + } else { + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + } + + const delay = Random.between(DELAY_BETWEEN_MESSAGES.min, DELAY_BETWEEN_MESSAGES.max); + await sleep(delay); + } + + // Last message is always from visitor so that the chat doesn't get abandoned due to + // "Livechat_visitor_inactivity_timeout" setting + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + }); + + await Promise.all(promises); + }; + + before(async () => { + if (!IS_EE) { + return; + } + + await updateSetting('Livechat_visitor_inactivity_timeout', inactivityTimeout); + await updateSetting('Livechat_enable_business_hours', false); + + // create dummy test data for further tests + const { department: createdDept, agent: agent1 } = await createDepartmentWithAnOnlineAgent(); + department = createdDept; + + console.log('department', department.name); + + const agent2 = await createAnOnlineAgent(); + await addOrRemoveAgentFromDepartment(department._id, { agentId: agent2.user._id, username: agent2.user.username }, true); + agents.push(agent1); + agents.push(agent2); + + const roomCreationStart = moment(); + // start a few chats + const promises = Array.from(Array(TOTAL_ROOMS).keys()).map((i) => { + // 2 rooms by agent 1 + if (i < 2) { + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent1.credentials }); + } + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent2.credentials }); + }); + + const results = await Promise.all(promises); + + const chatInfo = results.map((result) => ({ room: result.room, visitor: result.visitor })); + + // simulate messages being exchanged between agents and visitors + await simulateRealtimeConversation(chatInfo); + + // put a chat on hold + await sendAgentMessage(chatInfo[1].room._id); + await placeRoomOnHold(chatInfo[1].room._id); + // close a chat + await closeOmnichannelRoom(chatInfo[4].room._id); + const room5ChatDuration = moment().diff(roomCreationStart, 'seconds'); + // close an abandoned chat + await sendAgentMessage(chatInfo[5].room._id); + await sleep(inactivityTimeout * 1000); // wait for the chat to be considered abandoned + await closeOmnichannelRoom(chatInfo[5].room._id); + const room6ChatDuration = moment().diff(roomCreationStart, 'seconds'); + + avgClosedRoomChatDuration = (room5ChatDuration + room6ChatDuration) / 2; + }); + describe('livechat/analytics/dashboards/conversation-totalizers', () => { const expectedMetrics = [ 'Total_conversations', @@ -25,7 +143,7 @@ describe('LIVECHAT - dashboards', function () { 'Total_visitors', ]; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/conversation-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -33,7 +151,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of conversation totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/conversation-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -47,12 +165,51 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/conversation-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + expect(result.body.totalizers).to.have.lengthOf(5); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 60 }, + { title: 'Total_visitors', value: 7 }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + const maxMessages = TOTAL_MESSAGES.max * TOTAL_ROOMS; + + const totalMessages = result.body.totalizers.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + expect(totalMessagesValue).to.be.lessThanOrEqual(maxMessages); + }); }); describe('livechat/analytics/dashboards/productivity-totalizers', () => { const expectedMetrics = ['Avg_response_time', 'Avg_first_response_time', 'Avg_reaction_time', 'Avg_of_waiting_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -60,7 +217,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -74,12 +231,41 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = [ + // // There's a bug in the code for calculation of these 3 values. + // // Due to which it always return 0 + // { title: 'Avg_response_time', value: '00:00:00' }, + // { title: 'Avg_first_response_time', value: '00:00:00' }, + // { title: 'Avg_reaction_time', value: '00:00:00' }, + + // { title: 'Avg_of_waiting_time', value: '00:00:03' }, // approx 3, 5 delta + // ]; + + const avgWaitingTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_waiting_time'); + expect(avgWaitingTime).to.not.be.undefined; + + const avgWaitingTimeValue = moment.duration(avgWaitingTime.value).asSeconds(); + expect(avgWaitingTimeValue).to.be.closeTo(DELAY_BETWEEN_MESSAGES.max / 1000, 5); + }); }); describe('livechat/analytics/dashboards/chats-totalizers', () => { const expectedMetrics = ['Total_abandoned_chats', 'Avg_of_abandoned_chats', 'Avg_of_chat_duration_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/chats-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -87,7 +273,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of chats totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/chats-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -101,12 +287,45 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/chats-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = [ + { title: 'Total_abandoned_chats', value: 1 }, + { title: 'Avg_of_abandoned_chats', value: '14%' }, + // { title: 'Avg_of_chat_duration_time', value: '00:00:01' }, + ]; + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + expected.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const resultAverageChatDuration = result.body.totalizers.find((item: any) => item.title === 'Avg_of_chat_duration_time'); + expect(resultAverageChatDuration).to.not.be.undefined; + + const resultAverageChatDurationValue = moment.duration(resultAverageChatDuration.value).asSeconds(); + expect(resultAverageChatDurationValue).to.be.closeTo(avgClosedRoomChatDuration, 5); // Keep a margin of 3 seconds + }); }); describe('livechat/analytics/dashboards/agents-productivity-totalizers', () => { const expectedMetrics = ['Busiest_time', 'Avg_of_available_service_time', 'Avg_of_service_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get( api('livechat/analytics/dashboards/agents-productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z'), @@ -116,7 +335,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of agents productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get( api('livechat/analytics/dashboards/agents-productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z'), @@ -132,11 +351,40 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/agents-productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // [ + // { title: 'Busiest_time', value: '- -' }, + // { title: 'Avg_of_available_service_time', value: '00:00:00' }, + // { title: 'Avg_of_service_time', value: '00:00:16' } approx 17, 6 delta + // ], + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + const avgServiceTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_service_time'); + + expect(avgServiceTime).to.not.be.undefined; + const avgServiceTimeValue = moment.duration(avgServiceTime.value).asSeconds(); + const minChatDuration = (DELAY_BETWEEN_MESSAGES.min * TOTAL_MESSAGES.min) / 1000; + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + expect(avgServiceTimeValue).to.be.closeTo((minChatDuration + maxChatDuration) / 2, 10); + }); }); describe('livechat/analytics/dashboards/charts/chats', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -144,7 +392,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -157,11 +405,35 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('queued'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + open: 4, + closed: 2, + queued: 0, + onhold: 1, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/chats-per-agent', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-agent?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -169,7 +441,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by agent', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-agent?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -179,11 +451,39 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('success', true); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-agent')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + agent0: { open: 1, closed: 0, onhold: 1 }, + agent1: { open: 3, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const agent0 = result.body[agents[0].user.username as string]; + const agent1 = result.body[agents[1].user.username as string]; + + Object.entries(expected.agent0).forEach(([key, value]) => { + expect(agent0).to.have.property(key, value); + }); + Object.entries(expected.agent1).forEach(([key, value]) => { + expect(agent1).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/agents-status', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/agents-status')) .set(credentials) @@ -191,7 +491,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with agents status metrics', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/agents-status')) .set(credentials) @@ -205,11 +505,36 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('available'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/agents-status')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // TODO: We can improve tests further by creating some agents with different status + const expected = { + offline: 0, + away: 0, + busy: 0, + available: 2, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/chats-per-department', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-department?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -217,7 +542,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by department', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-department?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -227,11 +552,34 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('success', true); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-department')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + department0: { open: 5, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const department0 = result.body[department.name]; + + Object.entries(expected.department0).forEach(([key, value]) => { + expect(department0).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/timings', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/timings?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -239,7 +587,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by department', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/timings?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -258,11 +606,52 @@ describe('LIVECHAT - dashboards', function () { expect(res.body.chatDuration).to.have.property('longest'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/timings')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = { + // response: { avg: 0, longest: 0.207 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // reaction: { avg: 0, longest: 0.221 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // chatDuration: { avg: 0, longest: 0.18 }, // avg should be about avgClosedRoomChatDuration, and longest should be greater than avgClosedRoomChatDuration and within delta of 20 + // success: true, + // }; + + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + + const responseValues = result.body.response; + expect(responseValues).to.have.property('avg'); + expect(responseValues).to.have.property('longest'); + expect(responseValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(responseValues.longest).to.be.lessThan(maxChatDuration); + + const reactionValues = result.body.reaction; + expect(reactionValues).to.have.property('avg'); + expect(reactionValues).to.have.property('longest'); + expect(reactionValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(reactionValues.longest).to.be.lessThan(maxChatDuration); + + const chatDurationValues = result.body.chatDuration; + expect(chatDurationValues).to.have.property('avg'); + expect(chatDurationValues).to.have.property('longest'); + expect(chatDurationValues.avg).to.be.closeTo(avgClosedRoomChatDuration, 5); + expect(chatDurationValues.longest).to.be.greaterThan(avgClosedRoomChatDuration); + expect(chatDurationValues.longest).to.be.lessThan(avgClosedRoomChatDuration + 20); + }); }); describe('livechat/analytics/agent-overview', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/agent-overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: 'Total_conversations' }) @@ -271,7 +660,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an "invalid-chart-name error" when the chart name is empty', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/agent-overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) @@ -305,11 +694,37 @@ describe('LIVECHAT - dashboards', function () { expect(result.body.head).to.be.an('array'); expect(result.body.data).to.be.an('array'); }); + (IS_EE ? it : it.skip)('should return agent overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: today, name: 'Total_conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.have.lengthOf(2); + + const user1Data = result.body.data.find((data: any) => data.name === agents[0].user.username); + const user2Data = result.body.data.find((data: any) => data.name === agents[1].user.username); + + expect(user1Data).to.not.be.undefined; + expect(user2Data).to.not.be.undefined; + + expect(user1Data).to.have.property('value', '28.57%'); + expect(user2Data).to.have.property('value', '71.43%'); + }); }); describe('livechat/analytics/overview', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: 'Conversations' }) @@ -318,7 +733,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an "invalid-chart-name error" when the chart name is empty', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) @@ -351,5 +766,43 @@ describe('LIVECHAT - dashboards', function () { expect(result.body[0]).to.have.property('title', 'Total_conversations'); expect(result.body[0]).to.have.property('value', 0); }); + (IS_EE ? it : it.skip)('should return analytics overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: today, name: 'Conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 6 }, + // { title: 'Busiest_day', value: moment().format('dddd') }, // TODO: need to check y this return a day before + { title: 'Conversations_per_day', value: '3.50' }, + { title: 'Busiest_time', value: '- -' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + const maxMessages = TOTAL_MESSAGES.max * TOTAL_ROOMS; + + const totalMessages = result.body.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + expect(totalMessagesValue).to.be.lessThanOrEqual(maxMessages); + }); }); }); diff --git a/packages/random/src/NodeRandomGenerator.ts b/packages/random/src/NodeRandomGenerator.ts index b9d556c6ac07..8c9f239413ca 100644 --- a/packages/random/src/NodeRandomGenerator.ts +++ b/packages/random/src/NodeRandomGenerator.ts @@ -38,6 +38,16 @@ export class NodeRandomGenerator extends RandomGenerator { return result.substring(0, digits); } + /** + * @name Random.between Returns a random integer between min and max, inclusive. + * @param min Minimum value (inclusive) + * @param max Maximum value (inclusive) + * @returns A random integer between min and max, inclusive. + */ + between(min: number, max: number) { + return Math.floor(this.fraction() * (max - min + 1)) + min; + } + protected safelyCreateWithSeeds(...seeds: readonly unknown[]) { return new AleaRandomGenerator({ seeds }); } From e7cff82b5d0ba3a5dbdb22f03000568023420b5c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 6 Oct 2023 16:32:24 -0300 Subject: [PATCH 04/17] chore: add missing `_id` field to `AtLeast` (#30592) --- apps/meteor/lib/callbacks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 2d683bf27e2a..169144cc2788 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -57,7 +57,7 @@ interface EventLikeCallbackSignatures { 'livechat.afterTakeInquiry': (inq: InquiryWithAgentInfo, agent: { agentId: string; username: string }) => void; 'livechat.afterAgentRemoved': (params: { agent: Pick }) => void; 'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void; - 'beforeAddedToRoom': (params: { user: AtLeast; inviter: IUser }) => void; + 'beforeAddedToRoom': (params: { user: AtLeast; inviter: IUser }) => void; 'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id'] }) => void; 'beforeDeleteRoom': (params: IRoom) => void; 'beforeJoinDefaultChannels': (user: IUser) => void; From 3a62ac4eceaafdf33545cf05403b7291c847ef5d Mon Sep 17 00:00:00 2001 From: Guilherme Jun Grillo <48109548+guijun13@users.noreply.github.com> Date: Fri, 6 Oct 2023 17:19:15 -0300 Subject: [PATCH 05/17] fix: user dropdown menu position on RTL layout (#30490) --- .changeset/sweet-feet-relate.md | 5 +++++ apps/meteor/client/sidebar/header/UserMenu.tsx | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/sweet-feet-relate.md diff --git a/.changeset/sweet-feet-relate.md b/.changeset/sweet-feet-relate.md new file mode 100644 index 000000000000..f7da740ebcc0 --- /dev/null +++ b/.changeset/sweet-feet-relate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: user dropdown menu position on RTL layout diff --git a/apps/meteor/client/sidebar/header/UserMenu.tsx b/apps/meteor/client/sidebar/header/UserMenu.tsx index 9fcc7a0d2274..a53836eda311 100644 --- a/apps/meteor/client/sidebar/header/UserMenu.tsx +++ b/apps/meteor/client/sidebar/header/UserMenu.tsx @@ -24,6 +24,7 @@ const UserMenu = ({ user }: { user: IUser }) => { } + placement='bottom-end' selectionMode='multiple' sections={sections} title={t('User_menu')} @@ -36,6 +37,7 @@ const UserMenu = ({ user }: { user: IUser }) => { } medium + placement='bottom-end' selectionMode='multiple' sections={sections} title={t('User_menu')} From 6412ea7c32b8bf0145972f6be2d62eb402ae4fbf Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 6 Oct 2023 18:17:22 -0300 Subject: [PATCH 06/17] chore: Add required visual indication on `EditRoom` name input (#30589) Co-authored-by: Martin Schoeler Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../client/views/admin/rooms/EditRoom.tsx | 2 +- .../Info/EditRoomInfo/EditChannel.js | 113 +++++++++--------- 2 files changed, 59 insertions(+), 56 deletions(-) diff --git a/apps/meteor/client/views/admin/rooms/EditRoom.tsx b/apps/meteor/client/views/admin/rooms/EditRoom.tsx index c3b772c74748..b12b5e3e1ab9 100644 --- a/apps/meteor/client/views/admin/rooms/EditRoom.tsx +++ b/apps/meteor/client/views/admin/rooms/EditRoom.tsx @@ -225,7 +225,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps): ReactElement => )} - {t('Name')} + {t('Name')} diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js index be7a8b1e4238..22c84fbdebf1 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js @@ -9,6 +9,9 @@ import { Callout, NumberInput, FieldGroup, + FieldLabel, + FieldRow, + FieldHint, Button, ButtonGroup, Box, @@ -309,87 +312,87 @@ function EditChannel({ room, onClickClose, onClickBack }) { - {t('Name')} - + {t('Name')} + - + {canViewDescription && ( - {t('Description')} - + {t('Description')} + - + )} {canViewAnnouncement && ( - {t('Announcement')} - + {t('Announcement')} + - + )} {canViewTopic && ( - {t('Topic')} - + {t('Topic')} + - + )} {canViewType && ( - {t('Private')} - + {t('Private')} + - + - {t('Teams_New_Private_Description_Enabled')} + {t('Teams_New_Private_Description_Enabled')} )} {canViewReadOnly && ( - {t('Read_only')} - + {t('Read_only')} + - + - {t('Only_authorized_users_can_write_new_messages')} + {t('Only_authorized_users_can_write_new_messages')} )} {readOnly && ( - {t('React_when_read_only')} - + {t('React_when_read_only')} + - + - {t('Only_authorized_users_can_react_to_messages')} + {t('Only_authorized_users_can_react_to_messages')} )} {canViewArchived && ( - {t('Room_archivation_state_true')} - + {t('Room_archivation_state_true')} + - + )} {canViewJoinCode && ( - {t('Password_to_access')} - + {t('Password_to_access')} + - + - + - + )} {canViewHideSysMes && ( - {t('Hide_System_Messages')} - + {t('Hide_System_Messages')} + - + - + - + )} {canViewEncrypted && ( - {t('Encrypted')} - + {t('Encrypted')} + - + )} @@ -437,22 +440,22 @@ function EditChannel({ room, onClickClose, onClickBack }) { - {t('RetentionPolicyRoom_Enabled')} - + {t('RetentionPolicyRoom_Enabled')} + - + - {t('RetentionPolicyRoom_OverrideGlobal')} - + {t('RetentionPolicyRoom_OverrideGlobal')} + - + {retentionOverrideGlobal && ( @@ -461,25 +464,25 @@ function EditChannel({ room, onClickClose, onClickBack }) { {t('RetentionPolicyRoom_ReadTheDocs')} - {t('RetentionPolicyRoom_MaxAge', { max: maxAgeDefault })} - + {t('RetentionPolicyRoom_MaxAge', { max: maxAgeDefault })} + - + - {t('RetentionPolicyRoom_ExcludePinned')} - + {t('RetentionPolicyRoom_ExcludePinned')} + - + - {t('RetentionPolicyRoom_FilesOnly')} - + {t('RetentionPolicyRoom_FilesOnly')} + - + From bdc9d8ce27e1c865ea7bd11ab60f9784e1a98e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Fri, 6 Oct 2023 19:29:43 -0300 Subject: [PATCH 07/17] chore: improve message link focus (#30578) --- apps/meteor/client/components/message/MessageContentBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/components/message/MessageContentBody.tsx b/apps/meteor/client/components/message/MessageContentBody.tsx index 4674528a483f..5552e6da0745 100644 --- a/apps/meteor/client/components/message/MessageContentBody.tsx +++ b/apps/meteor/client/components/message/MessageContentBody.tsx @@ -46,7 +46,7 @@ const MessageContentBody = ({ mentions, channels, md, searchText }: MessageConte text-decoration: underline; } &:focus { - border: 2px solid ${Palette.stroke['stroke-extra-light-highlight']}; + box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; border-radius: 2px; } } From a8718eddc03cda4907e6c20de151ec9e982976cd Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 6 Oct 2023 20:29:39 -0300 Subject: [PATCH 08/17] feat: New permission to kick users (#30535) Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com> --- .changeset/seven-carpets-march.md | 5 ++ apps/meteor/app/api/server/v1/groups.ts | 74 ++++++++++--------- .../server/constant/permissions.ts | 2 + .../rocketchat-i18n/i18n/en.i18n.json | 4 + apps/meteor/server/lib/migrations.ts | 27 +++++-- .../server/methods/removeUserFromRoom.ts | 28 ++++--- apps/meteor/server/startup/migrations/xrun.js | 6 +- .../core-typings/src/migrations/IControl.ts | 1 + 8 files changed, 96 insertions(+), 51 deletions(-) create mode 100644 .changeset/seven-carpets-march.md diff --git a/.changeset/seven-carpets-march.md b/.changeset/seven-carpets-march.md new file mode 100644 index 000000000000..46fd1b7ddb62 --- /dev/null +++ b/.changeset/seven-carpets-march.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Add new permission to allow kick users from rooms without being a member diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index ef18d4256348..8f2999cee71e 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -26,29 +26,7 @@ import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; -// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property -async function findPrivateGroupByIdOrName({ - params, - checkedArchived = true, - userId, -}: { - params: - | { - roomId?: string; - } - | { - roomName?: string; - }; - userId: string; - checkedArchived?: boolean; -}): Promise<{ - rid: string; - open: boolean; - ro: boolean; - t: string; - name: string; - broadcast: boolean; -}> { +async function getRoomFromParams(params: { roomId?: string } | { roomName?: string }): Promise { if ( (!('roomId' in params) && !('roomName' in params)) || ('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName) @@ -68,17 +46,48 @@ async function findPrivateGroupByIdOrName({ broadcast: 1, }, }; - let room: IRoom | null = null; - if ('roomId' in params) { - room = await Rooms.findOneById(params.roomId || '', roomOptions); - } else if ('roomName' in params) { - room = await Rooms.findOneByName(params.roomName || '', roomOptions); - } + + const room = await (() => { + if ('roomId' in params) { + return Rooms.findOneById(params.roomId || '', roomOptions); + } + if ('roomName' in params) { + return Rooms.findOneByName(params.roomName || '', roomOptions); + } + })(); if (!room || room.t !== 'p') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); } + return room; +} + +// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +async function findPrivateGroupByIdOrName({ + params, + checkedArchived = true, + userId, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + userId: string; + checkedArchived?: boolean; +}): Promise<{ + rid: string; + open: boolean; + ro: boolean; + t: string; + name: string; + broadcast: boolean; +}> { + const room = await getRoomFromParams(params); + const user = await Users.findOneById(userId, { projections: { username: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { @@ -585,17 +594,14 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const findResult = await findPrivateGroupByIdOrName({ - params: this.bodyParams, - userId: this.userId, - }); + const room = await getRoomFromParams(this.bodyParams); const user = await getUserFromParams(this.bodyParams); if (!user?.username) { return API.v1.failure('Invalid user'); } - await removeUserFromRoomMethod(this.userId, { rid: findResult.rid, username: user.username }); + await removeUserFromRoomMethod(this.userId, { rid: room._id, username: user.username }); return API.v1.success(); }, diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index fc917028c33f..7b5f1594e5c3 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -10,6 +10,8 @@ export const permissions = [ { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, { _id: 'add-user-to-any-c-room', roles: ['admin'] }, { _id: 'add-user-to-any-p-room', roles: [] }, + { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, + { _id: 'kick-user-from-any-p-room', roles: [] }, { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, { _id: 'archive-room', roles: ['admin', 'owner'] }, { _id: 'assign-admin-role', roles: ['admin'] }, diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index e713f095f490..91993ff443fb 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2762,6 +2762,10 @@ "Jump_to_message": "Jump to message", "Jump_to_recent_messages": "Jump to recent messages", "Just_invited_people_can_access_this_channel": "Just invited people can access this channel.", + "kick-user-from-any-c-room": "Kick User from Any Public Channel", + "kick-user-from-any-c-room_description": "Permission to kick a user from any public channel", + "kick-user-from-any-p-room": "Kick User from Any Private Channel", + "kick-user-from-any-p-room_description": "Permission to kick a user from any private channel", "Katex_Dollar_Syntax": "Allow Dollar Syntax", "Katex_Dollar_Syntax_Description": "Allow using $$katex block$$ and $inline katex$ syntaxes", "Katex_Enabled": "Katex Enabled", diff --git a/apps/meteor/server/lib/migrations.ts b/apps/meteor/server/lib/migrations.ts index da3aeec761e6..f70b5bcca9ff 100644 --- a/apps/meteor/server/lib/migrations.ts +++ b/apps/meteor/server/lib/migrations.ts @@ -292,9 +292,24 @@ export async function migrateDatabase(targetVersion: 'latest' | number, subcomma return true; } -export const onFreshInstall = - (await getControl()).version !== 0 - ? async (): Promise => { - /* noop */ - } - : (fn: () => unknown): unknown => fn(); +export async function onServerVersionChange(cb: () => Promise): Promise { + const result = await Migrations.findOneAndUpdate( + { + _id: 'upgrade', + }, + { + $set: { + hash: Info.commit.hash, + }, + }, + { + upsert: true, + }, + ); + + if (result.value?.hash === Info.commit.hash) { + return; + } + + await cb(); +} diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index ea5bfa9edcff..2f29b1f55039 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -4,7 +4,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { getUsersInRole } from '../../app/authorization/server'; +import { canAccessRoomAsync, getUsersInRole } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; @@ -35,8 +35,6 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri }); } - const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); - const fromUser = await Users.findOneById(fromId); if (!fromUser) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -44,13 +42,25 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { - projection: { _id: 1 }, - }); - if (!subscription) { - throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { - method: 'removeUserFromRoom', + // did this way so a ctrl-f would find the permission being used + const kickAnyUserPermission = room.t === 'c' ? 'kick-user-from-any-c-room' : 'kick-user-from-any-p-room'; + + const canKickAnyUser = await hasPermissionAsync(fromId, kickAnyUserPermission); + if (!canKickAnyUser && !(await canAccessRoomAsync(room, fromUser))) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); + + if (!canKickAnyUser) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { + projection: { _id: 1 }, }); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeUserFromRoom', + }); + } } if (await hasRoleAsync(removedUser._id, 'owner', room._id)) { diff --git a/apps/meteor/server/startup/migrations/xrun.js b/apps/meteor/server/startup/migrations/xrun.js index bd3d19a7cbee..1af7cb8ad8ad 100644 --- a/apps/meteor/server/startup/migrations/xrun.js +++ b/apps/meteor/server/startup/migrations/xrun.js @@ -1,9 +1,11 @@ import { upsertPermissions } from '../../../app/authorization/server/functions/upsertPermissions'; -import { migrateDatabase, onFreshInstall } from '../../lib/migrations'; +import { migrateDatabase, onServerVersionChange } from '../../lib/migrations'; const { MIGRATION_VERSION = 'latest' } = process.env; const [version, ...subcommands] = MIGRATION_VERSION.split(','); await migrateDatabase(version === 'latest' ? version : parseInt(version), subcommands); -await onFreshInstall(upsertPermissions); + +// if the server is starting with a different version we update the permissions +await onServerVersionChange(() => upsertPermissions()); diff --git a/packages/core-typings/src/migrations/IControl.ts b/packages/core-typings/src/migrations/IControl.ts index 9ff993703550..3f89ce730f1a 100644 --- a/packages/core-typings/src/migrations/IControl.ts +++ b/packages/core-typings/src/migrations/IControl.ts @@ -2,6 +2,7 @@ export type IControl = { _id: string; version: number; locked: boolean; + hash?: string; buildAt?: string | Date; lockedAt?: string | Date; }; From 365e5fe3312925e4319ae2b5b84fc7c73601547f Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Mon, 9 Oct 2023 06:58:24 -0300 Subject: [PATCH 09/17] fix: don't save the filter value on offlineMessageChannelName (#30576) --- .../omnichannel/departments/EditDepartment.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index 46919307b052..bb1d5b057437 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -15,10 +15,10 @@ import { Button, PaginatedSelectFiltered, } from '@rocket.chat/fuselage'; -import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useDebouncedValue, useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { validateEmail } from '../../../../lib/emailValidator'; @@ -130,10 +130,13 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen } = useForm({ mode: 'onChange', defaultValues: initialValues }); const requestTagBeforeClosingChat = watch('requestTagBeforeClosingChat'); - const offlineMessageChannelName = watch('offlineMessageChannelName'); + + const [fallbackFilter, setFallbackFilter] = useState(''); + + const debouncedFallbackFilter = useDebouncedValue(fallbackFilter, 500); const { itemsList: RoomsList, loadMoreItems: loadMoreRooms } = useRoomsList( - useMemo(() => ({ text: offlineMessageChannelName }), [offlineMessageChannelName]), + useMemo(() => ({ text: debouncedFallbackFilter }), [debouncedFallbackFilter]), ); const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList); @@ -324,13 +327,14 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen value={value} onChange={onChange} flexShrink={0} - filter={value} - setFilter={onChange} + filter={fallbackFilter} + setFilter={setFallbackFilter as (value?: string | number) => void} options={roomsItems} placeholder={t('Channel_name')} endReached={ roomsPhase === AsyncStatePhase.LOADING ? () => undefined : (start) => loadMoreRooms(start, Math.min(50, roomsTotal)) } + aria-busy={fallbackFilter !== debouncedFallbackFilter} /> )} /> From 0ab30f88e628b38ea12e5d0475d2edb47a866d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Mon, 9 Oct 2023 14:06:22 -0300 Subject: [PATCH 10/17] chore: Change Records page name to Reports (#30513) --- apps/meteor/client/views/admin/routes.tsx | 6 +++--- apps/meteor/client/views/admin/sidebarItems.ts | 4 ++-- .../meteor/client/views/admin/viewLogs/AnalyticsReports.tsx | 5 ++++- apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx | 2 +- apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json | 6 +++--- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index a25bea5affaa..bea10777d66b 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -70,8 +70,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/admin/registration/:page?'; }; 'admin-view-logs': { - pathname: '/admin/records'; - pattern: '/admin/records'; + pathname: '/admin/reports'; + pattern: '/admin/reports'; }; 'federation-dashboard': { pathname: '/admin/federation'; @@ -193,7 +193,7 @@ registerAdminRoute('/registration/:page?', { component: lazy(() => import('./cloud/CloudRoute')), }); -registerAdminRoute('/records', { +registerAdminRoute('/reports', { name: 'admin-view-logs', component: lazy(() => import('./viewLogs/ViewLogsRoute')), }); diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 50a3284b5ed1..2beee76cee02 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -112,8 +112,8 @@ export const { permissionGranted: (): boolean => hasPermission('run-import'), }, { - href: '/admin/records', - i18nLabel: 'Records', + href: '/admin/reports', + i18nLabel: 'Reports', icon: 'post', permissionGranted: (): boolean => hasPermission('view-logs'), }, diff --git a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx index 7771298ceb73..cd300c14a481 100644 --- a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx +++ b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx @@ -18,7 +18,10 @@ const AnalyticsReports = () => { {t('How_and_why_we_collect_usage_data')} - {t('Analytics_page_briefing')} + + {t('Analytics_page_briefing_first_paragraph')} + + {t('Analytics_page_briefing_second_paragraph')} {isSuccess &&
{JSON.stringify(data, null, '\t')}
} diff --git a/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx b/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx index 4463fec8f5bf..2c1613f3ef74 100644 --- a/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx +++ b/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx @@ -14,7 +14,7 @@ const ViewLogsPage = (): ReactElement => { return ( - + setTab('Logs')} selected={tab === 'Logs'}> diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 91993ff443fb..b1ac03bd7df7 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -424,7 +424,8 @@ "Analytics_features_users_Description": "Tracks custom events related to actions related to users (password reset times, profile picture change, etc).", "Analytics_Google": "Google Analytics", "Analytics_Google_id": "Tracking ID", - "Analytics_page_briefing": "Rocket.Chat collects anonymous usage data to identify how many instances are deployed and to improve the product for all users. We take your privacy seriously, so the usage data is encrypted and stored securely.", + "Analytics_page_briefing_first_paragraph": "Rocket.Chat collects anonymous usage data, such as feature usage and session lengths, to improve the product for everyone.", + "Analytics_page_briefing_second_paragraph": "We protect your privacy by never collecting personal or sensitive data. This section shows what is collected, reinforcing our commitment to transparency and trust.", "Analyze_practical_usage": "Analyze practical usage statistics about users, messages and channels", "and": "and", "And_more": "And {{length}} more", @@ -2484,7 +2485,7 @@ "Hospitality_Businness": "Hospitality Business", "hours": "hours", "Hours": "Hours", - "How_and_why_we_collect_usage_data": "How and why we collect usage data", + "How_and_why_we_collect_usage_data": "How and why usage data is collected", "How_friendly_was_the_chat_agent": "How friendly was the chat agent?", "How_knowledgeable_was_the_chat_agent": "How knowledgeable was the chat agent?", "How_long_to_wait_after_agent_goes_offline": "How Long to Wait After Agent Goes Offline", @@ -4182,7 +4183,6 @@ "Receive_Login_Detection_Emails_Description": "Receive an email each time a new login is detected on your account.", "Recent_Import_History": "Recent Import History", "Record": "Record", - "Records": "Records", "recording": "recording", "Redirect_URI": "Redirect URI", "Redirect_URL_does_not_match": "Redirect URL does not match", From a58f2a707b6f0933f7ecd263745ab3d0c49d0027 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 9 Oct 2023 11:21:09 -0600 Subject: [PATCH 11/17] regression: Undefined MAC count on startup cloud sync (#30605) --- apps/meteor/app/cloud/server/functions/buildRegistrationData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index f887c9e6395c..c2bd91e82dd8 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -89,7 +89,7 @@ export async function buildWorkspaceRegistrationData Date: Mon, 9 Oct 2023 14:33:45 -0300 Subject: [PATCH 12/17] chore: Improve cache of static files (#30290) --- .changeset/wicked-humans-hang.md | 5 +++ apps/meteor/app/cors/server/cors.ts | 41 +++++++++++++++++++ .../custom-sounds/client/lib/CustomSounds.ts | 29 ++++++------- apps/meteor/app/ui-master/server/inject.ts | 24 +++++++---- apps/meteor/app/utils/client/getURL.ts | 6 +++ 5 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 .changeset/wicked-humans-hang.md diff --git a/.changeset/wicked-humans-hang.md b/.changeset/wicked-humans-hang.md new file mode 100644 index 000000000000..e793bc978902 --- /dev/null +++ b/.changeset/wicked-humans-hang.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Improve cache of static files diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 03a42e45a17b..cb6fa94273a2 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -1,4 +1,6 @@ +import { createHash } from 'crypto'; import type http from 'http'; +import type { UrlWithParsedQuery } from 'url'; import url from 'url'; import { Logger } from '@rocket.chat/logger'; @@ -77,6 +79,19 @@ WebApp.rawConnectHandlers.use((_req: http.IncomingMessage, res: http.ServerRespo }); const _staticFilesMiddleware = WebAppInternals.staticFilesMiddleware; +declare module 'meteor/webapp' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebApp { + function categorizeRequest( + req: http.IncomingMessage, + ): { arch: string; path: string; url: UrlWithParsedQuery } & Record; + } +} + +// These routes already handle cache control on their own +const cacheControlledRoutes: Array = ['/assets', '/custom-sounds', '/emoji-custom', '/avatar', '/file-upload'].map( + (route) => new RegExp(`^${route}`, 'i'), +); // @ts-expect-error - accessing internal property of webapp WebAppInternals.staticFilesMiddleware = function ( @@ -86,6 +101,32 @@ WebAppInternals.staticFilesMiddleware = function ( next: NextFunction, ) { res.setHeader('Access-Control-Allow-Origin', '*'); + const { arch, path, url } = WebApp.categorizeRequest(req); + + if (Meteor.isProduction && !cacheControlledRoutes.some((regexp) => regexp.test(path))) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } + + // Prevent meteor_runtime_config.js to load from a different expected hash possibly causing + // a cache of the file for the wrong hash and start a client loop due to the mismatch + // of the hashes of ui versions which would be checked against a websocket response + if (path === '/meteor_runtime_config.js') { + const program = WebApp.clientPrograms[arch] as (typeof WebApp.clientPrograms)[string] & { + meteorRuntimeConfigHash?: string; + meteorRuntimeConfig: string; + }; + + if (!program?.meteorRuntimeConfigHash) { + program.meteorRuntimeConfigHash = createHash('sha1') + .update(JSON.stringify(encodeURIComponent(program.meteorRuntimeConfig))) + .digest('hex'); + } + + if (program.meteorRuntimeConfigHash !== url.query.hash) { + res.writeHead(404); + return res.end(); + } + } return _staticFilesMiddleware(staticFiles, req, res, next); }; diff --git a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts index a4f59136a1f9..f881c15f9886 100644 --- a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts +++ b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts @@ -7,21 +7,22 @@ import { getURL } from '../../../utils/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; const getCustomSoundId = (soundId: ICustomSound['_id']) => `custom-sound-${soundId}`; +const getAssetUrl = (asset: string, params?: Record) => getURL(asset, params, undefined, true); const defaultSounds = [ - { _id: 'chime', name: 'Chime', extension: 'mp3', src: getURL('sounds/chime.mp3') }, - { _id: 'door', name: 'Door', extension: 'mp3', src: getURL('sounds/door.mp3') }, - { _id: 'beep', name: 'Beep', extension: 'mp3', src: getURL('sounds/beep.mp3') }, - { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getURL('sounds/chelle.mp3') }, - { _id: 'ding', name: 'Ding', extension: 'mp3', src: getURL('sounds/ding.mp3') }, - { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getURL('sounds/droplet.mp3') }, - { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getURL('sounds/highbell.mp3') }, - { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getURL('sounds/seasons.mp3') }, - { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getURL('sounds/telephone.mp3') }, - { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getURL('sounds/outbound-call-ringing.mp3') }, - { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getURL('sounds/call-ended.mp3') }, - { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getURL('sounds/dialtone.mp3') }, - { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getURL('sounds/ringtone.mp3') }, + { _id: 'chime', name: 'Chime', extension: 'mp3', src: getAssetUrl('sounds/chime.mp3') }, + { _id: 'door', name: 'Door', extension: 'mp3', src: getAssetUrl('sounds/door.mp3') }, + { _id: 'beep', name: 'Beep', extension: 'mp3', src: getAssetUrl('sounds/beep.mp3') }, + { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getAssetUrl('sounds/chelle.mp3') }, + { _id: 'ding', name: 'Ding', extension: 'mp3', src: getAssetUrl('sounds/ding.mp3') }, + { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getAssetUrl('sounds/droplet.mp3') }, + { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getAssetUrl('sounds/highbell.mp3') }, + { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getAssetUrl('sounds/seasons.mp3') }, + { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getAssetUrl('sounds/telephone.mp3') }, + { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getAssetUrl('sounds/outbound-call-ringing.mp3') }, + { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getAssetUrl('sounds/call-ended.mp3') }, + { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getAssetUrl('sounds/dialtone.mp3') }, + { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getAssetUrl('sounds/ringtone.mp3') }, ]; class CustomSoundsClass { @@ -85,7 +86,7 @@ class CustomSoundsClass { } getURL(sound: ICustomSound) { - return getURL(`/custom-sounds/${sound._id}.${sound.extension}?_dc=${sound.random || 0}`); + return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 }); } getList() { diff --git a/apps/meteor/app/ui-master/server/inject.ts b/apps/meteor/app/ui-master/server/inject.ts index 78112bcee343..1e00a0e47433 100644 --- a/apps/meteor/app/ui-master/server/inject.ts +++ b/apps/meteor/app/ui-master/server/inject.ts @@ -32,7 +32,7 @@ const callback: NextHandleFunction = (req, res, next) => { return; } - const injection = headInjections.get(pathname.replace(/^\//, '')) as Injection | undefined; + const injection = headInjections.get(pathname.replace(/^\//, '').split('_')[0]) as Injection | undefined; if (!injection || typeof injection === 'string') { next(); @@ -76,27 +76,37 @@ export const injectIntoHead = (key: string, value: Injection): void => { }; export const addScript = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addScript - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.js`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.js`, { + + injectIntoHead(key, { type: 'JS', - tag: ``, + tag: ``, content, }); }; export const addStyle = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addStyle - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.css`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.css`, { + + injectIntoHead(key, { type: 'CSS', - tag: ``, + tag: ``, content, }); }; diff --git a/apps/meteor/app/utils/client/getURL.ts b/apps/meteor/app/utils/client/getURL.ts index 91ef0989bd19..040b6dfa9dc2 100644 --- a/apps/meteor/app/utils/client/getURL.ts +++ b/apps/meteor/app/utils/client/getURL.ts @@ -1,13 +1,19 @@ import { settings } from '../../settings/client'; import { getURLWithoutSettings } from '../lib/getURL'; +import { Info } from '../rocketchat.info'; export const getURL = function ( path: string, // eslint-disable-next-line @typescript-eslint/naming-convention params: Record = {}, cloudDeepLinkUrl?: string, + cacheKey?: boolean, ): string { const cdnPrefix = settings.get('CDN_PREFIX') || ''; const siteUrl = settings.get('Site_Url') || ''; + if (cacheKey) { + path += `${path.includes('?') ? '&' : '?'}cacheKey=${Info.version}`; + } + return getURLWithoutSettings(path, params, cdnPrefix, siteUrl, cloudDeepLinkUrl); }; From 06a8e302890c15b29c29a483d5eab8675f0447dc Mon Sep 17 00:00:00 2001 From: csuadev <72958726+csuadev@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:25:57 -0500 Subject: [PATCH 13/17] chore: Change plan name Enterprise to Premium on marketplace (#30487) --- .changeset/odd-hounds-thank.md | 5 +++++ .../GenericUpsellModal/GenericUpsellModal.tsx | 2 +- .../views/marketplace/AppsPage/AppsFilters.tsx | 2 +- .../marketplace/AppsPage/AppsPageContent.tsx | 4 ++-- .../client/views/marketplace/BundleChips.tsx | 8 ++++---- .../marketplace/components/EnabledAppsCount.tsx | 2 +- .../marketplace/components/MarketplaceHeader.tsx | 2 +- .../client/views/marketplace/hooks/useAppInfo.ts | 2 +- .../views/marketplace/hooks/useAppsCountQuery.ts | 4 ++-- .../views/marketplace/hooks/useFilteredApps.ts | 4 ++-- .../client/views/marketplace/sidebarItems.tsx | 4 ++-- .../packages/rocketchat-i18n/i18n/en.i18n.json | 16 ++++++++++------ 12 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 .changeset/odd-hounds-thank.md diff --git a/.changeset/odd-hounds-thank.md b/.changeset/odd-hounds-thank.md new file mode 100644 index 000000000000..aaddc5d51a38 --- /dev/null +++ b/.changeset/odd-hounds-thank.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +chore: Change plan name Enterprise to Premium on marketplace diff --git a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx index ec9f852c8a8b..513b31dd81fa 100644 --- a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx +++ b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx @@ -42,7 +42,7 @@ const GenericUpsellModal = ({ {icon && } - {tagline ?? t('Enterprise_capability')} + {tagline ?? t('Premium_capability')} {title} diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx index 2fdbffda7d4d..f1332284c97f 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx @@ -50,7 +50,7 @@ const AppsFilters = ({ const appsSearchPlaceholders: { [key: string]: string } = { explore: t('Search_Apps'), - enterprise: t('Search_Enterprise_Apps'), + enterprise: t('Search_Premium_Apps'), installed: t('Search_Installed_Apps'), requested: t('Search_Requested_Apps'), private: t('Search_Private_apps'), diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx index 5b47634bff29..1bc7642e5afc 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx @@ -40,7 +40,7 @@ const AppsPageContent = (): ReactElement => { { id: 'all', label: t('All_Prices'), checked: true }, { id: 'free', label: t('Free_Apps'), checked: false }, { id: 'paid', label: t('Paid_Apps'), checked: false }, - { id: 'enterprise', label: t('Enterprise'), checked: false }, + { id: 'premium', label: t('Premium'), checked: false }, ], }); const freePaidFilterOnSelected = useRadioToggle(setFreePaidFilterStructure); @@ -89,7 +89,7 @@ const AppsPageContent = (): ReactElement => { const getAppsData = useCallback((): appsDataType => { switch (context) { - case 'enterprise': + case 'premium': case 'explore': case 'requested': return marketplaceApps; diff --git a/apps/meteor/client/views/marketplace/BundleChips.tsx b/apps/meteor/client/views/marketplace/BundleChips.tsx index 9f988534fe14..4e2953bd8519 100644 --- a/apps/meteor/client/views/marketplace/BundleChips.tsx +++ b/apps/meteor/client/views/marketplace/BundleChips.tsx @@ -18,15 +18,15 @@ const BundleChips = ({ bundledIn }: BundleChipsProps): ReactElement => { return ( <> - {bundledIn.map((bundle) => ( + {bundledIn.map(({ bundleId, bundleName }) => ( - {bundle.bundleName} + {bundleName} ))} diff --git a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx index 72cfa5474346..da135cbdedfc 100644 --- a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx +++ b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx @@ -15,7 +15,7 @@ const EnabledAppsCount = ({ percentage: number; limit: number; enabled: number; - context: 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; + context: 'private' | 'explore' | 'installed' | 'premium' | 'requested'; }): ReactElement | null => { const t = useTranslation(); diff --git a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx index f93cb1fcd339..7696801c3124 100644 --- a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx +++ b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx @@ -12,7 +12,7 @@ import EnabledAppsCount from './EnabledAppsCount'; const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => { const t = useTranslation(); const isAdmin = usePermission('manage-apps'); - const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; + const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested'; const route = useRoute('marketplace'); const setModal = useSetModal(); const result = useAppsCountQuery(context); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts index 7e69cdeef97c..44ab240ce7b3 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts @@ -36,7 +36,7 @@ export const useAppInfo = (appId: string, context: string): AppInfo | undefined } let appResult: App | undefined; - const marketplaceAppsContexts = ['explore', 'enterprise', 'requested']; + const marketplaceAppsContexts = ['explore', 'premium', 'requested']; if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps.value?.apps.find((app) => app.id === appId); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts index 10689c773479..a571eac3b71f 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts @@ -11,10 +11,10 @@ const getProgressBarValues = (numberOfEnabledApps: number, enabledAppsLimit: num percentage: Math.round((numberOfEnabledApps / enabledAppsLimit) * 100), }); -export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; +export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested'; export function isMarketplaceRouteContext(context: string): context is MarketplaceRouteContext { - return ['private', 'explore', 'installed', 'enterprise', 'requested'].includes(context); + return ['private', 'explore', 'installed', 'premium', 'requested'].includes(context); } export const useAppsCountQuery = (context: MarketplaceRouteContext) => { diff --git a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts index 1027aae75a8a..221990f7af2a 100644 --- a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts +++ b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts @@ -66,7 +66,7 @@ export const useFilteredApps = ({ const filterByPurchaseType: Record App[]> = { all: fallback, paid: (apps: App[]) => apps.filter(filterAppsByPaid), - enterprise: (apps: App[]) => apps.filter(filterAppsByEnterprise), + premium: (apps: App[]) => apps.filter(filterAppsByEnterprise), free: (apps: App[]) => apps.filter(filterAppsByFree), }; @@ -80,7 +80,7 @@ export const useFilteredApps = ({ explore: fallback, installed: fallback, private: fallback, - enterprise: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Enterprise')), + premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Enterprise')), requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed), }; diff --git a/apps/meteor/client/views/marketplace/sidebarItems.tsx b/apps/meteor/client/views/marketplace/sidebarItems.tsx index bafcc4e62c58..f829cccf3238 100644 --- a/apps/meteor/client/views/marketplace/sidebarItems.tsx +++ b/apps/meteor/client/views/marketplace/sidebarItems.tsx @@ -17,9 +17,9 @@ export const { permissionGranted: (): boolean => hasAtLeastOnePermission(['access-marketplace', 'manage-apps']), }, { - href: '/marketplace/enterprise', + href: '/marketplace/premium', icon: 'lightning', - i18nLabel: 'Enterprise', + i18nLabel: 'Premium', permissionGranted: (): boolean => hasAtLeastOnePermission(['access-marketplace', 'manage-apps']), }, { diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index b1ac03bd7df7..d4b28724b6e2 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -531,6 +531,7 @@ "Apps_context_installed": "Installed", "Apps_context_requested": "Requested", "Apps_context_private": "Private Apps", + "Apps_context_premium": "Premium", "Apps_Count_Enabled": "{{count}} app enabled", "Apps_Count_Enabled_plural": "{{count}} apps enabled", "Private_Apps_Count_Enabled": "{{count}} private app enabled", @@ -4551,6 +4552,7 @@ "Search_Installed_Apps": "Search installed apps", "Search_Private_apps": "Search private apps", "Search_Requested_Apps": "Search requested apps", + "Search_Premium_Apps": "Search Premium apps", "Search_by_file_name": "Search by file name", "Search_by_username": "Search by username", "Search_by_category": "Search by category", @@ -5946,9 +5948,9 @@ "Theme_light_description": "More accessible for individuals with visual impairments and a good choice for well-lit environments.", "Theme_dark": "Dark", "Theme_dark_description": "Reduce eye strain and fatigue in low-light conditions by minimizing the amount of light emitted by the screen.", - "Enable_of_limit_apps_currently_enabled": "**{{enabled}} of {{limit}} {{context}} apps currently enabled.** \n \nWorkspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. \n \n**{{appName}} will be disabled by default.** Disable another {{context}} app or upgrade to Enterprise to enable this app.", - "Enable_of_limit_apps_currently_enabled_exceeded": "**{{enabled}} of {{limit}} {{context}} apps currently enabled.** \n \nCommunity edition app limit has been exceeded. \n \nWorkspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. \n \n**{{appName}} will be disabled by default.** You will need to disable at least {{exceed}} other {{context}} apps or upgrade to Enterprise to enable this app.", - "Workspaces_on_Community_edition_install_app": "Workspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. Upgrade to Enterprise to enable unlimited apps.", + "Enable_of_limit_apps_currently_enabled": "**{{enabled}} of {{limit}} {{context}} apps currently enabled.** \n \nWorkspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. \n \n**{{appName}} will be disabled by default.** Disable another {{context}} app or upgrade to Premium to enable this app.", + "Enable_of_limit_apps_currently_enabled_exceeded": "**{{enabled}} of {{limit}} {{context}} apps currently enabled.** \n \nCommunity edition app limit has been exceeded. \n \nWorkspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. \n \n**{{appName}} will be disabled by default.** You will need to disable at least {{exceed}} other {{context}} apps or upgrade to Premium to enable this app.", + "Workspaces_on_Community_edition_install_app": "Workspaces on Community Edition can have up to {{limit}} {{context}} apps enabled. Upgrade to Premium to enable unlimited apps.", "Apps_Currently_Enabled": "{{enabled}} of {{limit}} {{context}} apps currently enabled.", "Disable_another_app": "Disable another app or upgrade to Enterprise to enable this app.", "Upload_anyway": "Upload anyway", @@ -5971,8 +5973,8 @@ "Create_a_password": "Create a password", "Create_an_account": "Create an account", "Get_all_apps": "Get all the apps your team needs", - "Workspaces_on_community_edition_trial_on": "Workspaces on Community Edition can have up to 5 marketplace apps and 3 private apps enabled. Start a free Enterprise trial to remove these limits today!", - "Workspaces_on_community_edition_trial_off": "Workspaces on Community Edition can have up to 5 marketplace apps and 3 private apps enabled. Upgrade to Enterprise to remove limits and supercharge your workspace.", + "Workspaces_on_community_edition_trial_on": "Workspaces on Community Edition can have up to 5 marketplace apps and 3 private apps enabled. Start a free Premium trial to remove these limits today!", + "Workspaces_on_community_edition_trial_off": "Workspaces on Community Edition can have up to 5 marketplace apps and 3 private apps enabled. Upgrade to Premium to remove limits and supercharge your workspace.", "No_private_apps_installed": "No private apps installed", "Private_apps_are_side-loaded": "Private apps are side-loaded and are not available on the Marketplace.", "Chat_transcript": "Chat transcript", @@ -6074,5 +6076,7 @@ "All_visible": "All visible", "Filter_by_room": "Filter by room type", "Filter_by_visibility": "Filter by visibility", - "Theme_Appearence": "Theme Appearence" + "Theme_Appearence": "Theme Appearence", + "Premium": "Premium", + "Premium_capability": "Premium capability" } From 181441047e080fcf82e857bd798479c615bebb24 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 9 Oct 2023 17:56:51 -0300 Subject: [PATCH 14/17] feat: License trigger cloud sync after reach limits (#30603) Co-authored-by: Guilherme Gazzo Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/meteor/ee/app/license/server/settings.ts | 49 ++--------- apps/meteor/ee/app/license/server/startup.ts | 87 +++++++++++++++++-- ee/packages/license/src/license.ts | 26 ++++-- 3 files changed, 108 insertions(+), 54 deletions(-) diff --git a/apps/meteor/ee/app/license/server/settings.ts b/apps/meteor/ee/app/license/server/settings.ts index 1bec7126ae85..ead088fd546d 100644 --- a/apps/meteor/ee/app/license/server/settings.ts +++ b/apps/meteor/ee/app/license/server/settings.ts @@ -1,8 +1,6 @@ -import { License } from '@rocket.chat/license'; -import { Settings } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { settings, settingsRegistry } from '../../../../app/settings/server'; +import { settingsRegistry } from '../../../../app/settings/server'; Meteor.startup(async () => { await settingsRegistry.addGroup('Enterprise', async function () { @@ -11,6 +9,12 @@ Meteor.startup(async () => { type: 'string', i18nLabel: 'Enterprise_License', }); + await this.add('Enterprise_License_Data', '', { + type: 'string', + hidden: true, + blocked: true, + public: false, + }); await this.add('Enterprise_License_Status', '', { readonly: true, type: 'string', @@ -19,42 +23,3 @@ Meteor.startup(async () => { }); }); }); - -settings.watch('Enterprise_License', async (license) => { - if (!license || String(license).trim() === '') { - return; - } - - if (license === process.env.ROCKETCHAT_LICENSE) { - return; - } - - try { - if (!(await License.setLicense(license))) { - await Settings.updateValueById('Enterprise_License_Status', 'Invalid'); - return; - } - } catch (_error) { - // do nothing - } - - await Settings.updateValueById('Enterprise_License_Status', 'Valid'); -}); - -if (process.env.ROCKETCHAT_LICENSE) { - try { - await License.setLicense(process.env.ROCKETCHAT_LICENSE); - } catch (_error) { - // do nothing - } - - Meteor.startup(async () => { - if (settings.get('Enterprise_License')) { - console.warn( - 'Rocket.Chat Enterprise: The license from your environment variable was ignored, please use only the admin setting from now on.', - ); - return; - } - await Settings.updateValueById('Enterprise_License', process.env.ROCKETCHAT_LICENSE); - }); -} diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index d3523282d1e8..765a4205a1a7 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -1,6 +1,8 @@ import { License } from '@rocket.chat/license'; -import { Subscriptions, Users } from '@rocket.chat/models'; +import { Subscriptions, Users, Settings } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; +import { syncWorkspace } from '../../../../app/cloud/server/functions/syncWorkspace'; import { settings } from '../../../../app/settings/server'; import { callbacks } from '../../../../lib/callbacks'; import { getAppCount } from './lib/getAppCount'; @@ -11,12 +13,87 @@ settings.watch('Site_Url', (value) => { } }); -callbacks.add('workspaceLicenseChanged', async (updatedLicense) => { +License.onValidateLicense(async () => { + await Settings.updateValueById('Enterprise_License', License.encryptedLicense); + await Settings.updateValueById('Enterprise_License_Status', 'Valid'); +}); + +License.onInvalidateLicense(async () => { + await Settings.updateValueById('Enterprise_License_Status', 'Invalid'); +}); + +const applyLicense = async (license: string, isNewLicense: boolean): Promise => { + const enterpriseLicense = (license ?? '').trim(); + if (!enterpriseLicense) { + return false; + } + + if (enterpriseLicense === License.encryptedLicense) { + return false; + } + try { - await License.setLicense(updatedLicense); - } catch (_error) { - // Ignore + return License.setLicense(enterpriseLicense, isNewLicense); + } catch { + return false; + } +}; + +const syncByTrigger = async (context: string) => { + if (!License.encryptedLicense) { + return; } + + const existingData = wrapExceptions(() => JSON.parse(settings.get('Enterprise_License_Data'))).catch(() => ({})) ?? {}; + + const date = new Date(); + + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + + const period = `${year}-${month}-${day}`; + + const [, , signed] = License.encryptedLicense.split('.'); + + // Check if this sync has already been done. Based on License, behavior. + if (existingData.signed === signed && existingData[context] === period) { + return; + } + + await Settings.updateValueById( + 'Enterprise_License_Data', + JSON.stringify({ + ...(existingData.signed === signed && existingData), + ...existingData, + [context]: period, + signed, + }), + ); + + await syncWorkspace(); +}; + +// When settings are loaded, apply the current license if there is one. +settings.onReady(async () => { + if (!(await applyLicense(settings.get('Enterprise_License') ?? '', false))) { + // License from the envvar is always treated as new, because it would have been saved on the setting if it was already in use. + if (process.env.ROCKETCHAT_LICENSE && !License.hasValidLicense()) { + await applyLicense(process.env.ROCKETCHAT_LICENSE, true); + } + } + + // After the current license is already loaded, watch the setting value to react to new licenses being applied. + settings.watch('Enterprise_License', async (license) => applyLicense(license, true)); + + callbacks.add('workspaceLicenseChanged', async (updatedLicense) => applyLicense(updatedLicense, true)); + + License.onBehaviorTriggered('prevent_action', (context) => syncByTrigger(`prevent_action_${context.limit}`)); + + License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTrigger(`start_fair_policy_${context.limit}`)); + + License.onBehaviorTriggered('disable_modules', async (context) => syncByTrigger(`disable_modules_${context.limit}`)); + }); License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount()); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index f2d8eef362bd..8449d4136810 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -66,6 +66,14 @@ export class LicenseManager extends Emitter { return this._valid; } + public get encryptedLicense(): string | undefined { + if (!this.hasValidLicense()) { + return undefined; + } + + return this._lockedLicense; + } + public async setWorkspaceUrl(url: string) { this.workspaceUrl = url.replace(/\/$/, '').replace(/^https?:\/\/(.*)$/, '$1'); @@ -106,7 +114,12 @@ export class LicenseManager extends Emitter { invalidateAll.call(this); } - private async setLicenseV3(newLicense: ILicenseV3, encryptedLicense: string, originalLicense?: ILicenseV2 | ILicenseV3): Promise { + private async setLicenseV3( + newLicense: ILicenseV3, + encryptedLicense: string, + originalLicense?: ILicenseV2 | ILicenseV3, + isNewLicense?: boolean, + ): Promise { const hadValidLicense = this.hasValidLicense(); this.clearLicenseData(); @@ -114,7 +127,6 @@ export class LicenseManager extends Emitter { this._unmodifiedLicense = originalLicense || newLicense; this._license = newLicense; - const isNewLicense = encryptedLicense !== this._lockedLicense; this._lockedLicense = encryptedLicense; await this.validateLicense({ isNewLicense }); @@ -127,8 +139,8 @@ export class LicenseManager extends Emitter { } } - private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string): Promise { - return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense); + private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string, isNewLicense?: boolean): Promise { + return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense, isNewLicense); } private isLicenseDuplicated(encryptedLicense: string): boolean { @@ -180,7 +192,7 @@ export class LicenseManager extends Emitter { licenseValidated.call(this); } - public async setLicense(encryptedLicense: string): Promise { + public async setLicense(encryptedLicense: string, isNewLicense = true): Promise { if (!(await validateFormat(encryptedLicense))) { throw new InvalidLicenseError(); } @@ -209,10 +221,10 @@ export class LicenseManager extends Emitter { logger.debug({ msg: 'license', decrypted }); if (!encryptedLicense.startsWith('RCV3_')) { - await this.setLicenseV2(decrypted, encryptedLicense); + await this.setLicenseV2(decrypted, encryptedLicense, isNewLicense); return true; } - await this.setLicenseV3(decrypted, encryptedLicense); + await this.setLicenseV3(decrypted, encryptedLicense, decrypted, isNewLicense); return true; } catch (e) { From 1f2b384c624db63feb621c7e8ddc4a7237bd36aa Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 9 Oct 2023 21:05:50 -0300 Subject: [PATCH 15/17] fix: cloud alerts (#30607) --- .changeset/shiny-pillows-run.md | 5 +++++ .../server/functions/getNewUpdates.ts | 22 +++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 .changeset/shiny-pillows-run.md diff --git a/.changeset/shiny-pillows-run.md b/.changeset/shiny-pillows-run.md new file mode 100644 index 000000000000..9a85d37a2f9d --- /dev/null +++ b/.changeset/shiny-pillows-run.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: cloud alerts not working diff --git a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts index ac0c0e443453..d17191a09be7 100644 --- a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts +++ b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts @@ -50,18 +50,16 @@ export const getNewUpdates = async () => { infoUrl: String, }), ], - alerts: [ - Match.Optional([ - Match.ObjectIncluding({ - id: String, - title: String, - text: String, - textArguments: [Match.Any], - modifiers: [String] as [StringConstructor], - infoUrl: String, - }), - ]), - ], + alerts: Match.Optional([ + Match.ObjectIncluding({ + id: String, + title: String, + text: String, + textArguments: [Match.Any], + modifiers: [String] as [StringConstructor], + infoUrl: String, + }), + ]), }), ); From ef1d37a0f497271ab37cfaf08b838482a148a74d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 9 Oct 2023 21:08:00 -0300 Subject: [PATCH 16/17] ci: fix lint task --- apps/meteor/ee/app/license/server/startup.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index 765a4205a1a7..8cce4d3d1410 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -93,7 +93,6 @@ settings.onReady(async () => { License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTrigger(`start_fair_policy_${context.limit}`)); License.onBehaviorTriggered('disable_modules', async (context) => syncByTrigger(`disable_modules_${context.limit}`)); - }); License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount()); From 4344d838a95cefd3ef10442ccf2f050207c636af Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:12:00 +0400 Subject: [PATCH 17/17] fix: Unable to send attachments via email as an omni-agent (#30525) --- .changeset/proud-shrimps-cheat.md | 5 +++++ .../server/features/EmailInbox/EmailInbox_Outgoing.ts | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/proud-shrimps-cheat.md diff --git a/.changeset/proud-shrimps-cheat.md b/.changeset/proud-shrimps-cheat.md new file mode 100644 index 000000000000..cad8bc8bfa32 --- /dev/null +++ b/.changeset/proud-shrimps-cheat.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Unable to send attachments via email as an omni-agent diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index 61ca75aa65d4..685c7f9e96dd 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -43,15 +43,15 @@ const sendErrorReplyMessage = async (error: string, options: any) => { return sendMessage(user, message, { _id: options.rid }); }; -const sendSuccessReplyMessage = async (options: any) => { - if (!options?.rid || !options?.msgId) { +const sendSuccessReplyMessage = async (options: { room: IOmnichannelRoom; msgId: string; sender: string }) => { + if (!options?.room?._id || !options?.msgId) { return; } const message = { groupable: false, msg: `@${options.sender} Attachment was sent successfully`, _id: String(Date.now()), - rid: options.rid, + rid: options.room._id, ts: new Date(), }; @@ -60,7 +60,7 @@ const sendSuccessReplyMessage = async (options: any) => { return; } - return sendMessage(user, message, { _id: options.rid }); + return sendMessage(user, message, options.room); }; async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promise<{ messageId: string }> { @@ -174,7 +174,7 @@ slashCommands.add({ return sendSuccessReplyMessage({ msgId: message._id, sender: message.u.username, - rid: room._id, + room, }); }, options: {