From cb3eef392bb2aa79efd9967962c32afd09d58232 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 29 Nov 2023 15:21:13 -0300 Subject: [PATCH] chore: small changes related to license ux (#31095) --- .../functions/getWorkspaceAccessToken.ts | 8 ++ .../getWorkspaceAccessTokenWithScope.ts | 7 -- .../registerPreIntentWorkspaceWizard.ts | 2 +- .../server/functions/syncWorkspace/index.ts | 21 ++-- .../syncWorkspace/legacySyncWorkspace.ts | 44 +++---- apps/meteor/app/cloud/server/index.ts | 28 ++++- .../SubscriptionCalloutLimits.tsx | 4 + .../admin/subscription/SubscriptionPage.tsx | 102 ++++++++-------- .../workspace/VersionCard/VersionCard.tsx | 25 +--- .../VersionCard/getVersionStatus.spec.ts | 110 ++++++++++++++++++ .../workspace/VersionCard/getVersionStatus.ts | 29 +++++ apps/meteor/ee/app/license/server/startup.ts | 6 +- .../rocketchat-i18n/i18n/en.i18n.json | 4 +- ee/packages/license/src/validation/logKind.ts | 11 ++ .../license/src/validation/validateLimits.ts | 22 +++- 15 files changed, 296 insertions(+), 127 deletions(-) create mode 100644 apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.spec.ts create mode 100644 apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.ts create mode 100644 ee/packages/license/src/validation/logKind.ts diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts index d5b92c97ef7..7e970edfdfc 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts @@ -46,6 +46,14 @@ export class CloudWorkspaceAccessTokenError extends Error { } } +export const isAbortError = (error: unknown): error is { type: 'AbortError' } => { + if (typeof error !== 'object' || error === null) { + return false; + } + + return 'type' in error && error.type === 'AbortError'; +}; + export class CloudWorkspaceAccessTokenEmptyError extends Error { constructor() { super('Workspace access token is empty'); diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts index 62d50630dd2..92ff94a4b8f 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts @@ -64,13 +64,6 @@ export async function getWorkspaceAccessTokenWithScope(scope = '', throwOnError expiresAt, }; } catch (err: any) { - SystemLogger.error({ - msg: 'Failed to get Workspace AccessToken from Rocket.Chat Cloud', - url: '/api/oauth/token', - scope, - err, - }); - if (err instanceof CloudWorkspaceAccessTokenError) { SystemLogger.error('Server has been unregistered from cloud'); void removeWorkspaceRegistrationInfo(); diff --git a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts index ce415d2aa98..e0865c24156 100644 --- a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts @@ -21,7 +21,7 @@ export async function registerPreIntentWorkspaceWizard(): Promise { const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { method: 'POST', body: regInfo, - timeout: 10 * 1000, + timeout: 3 * 1000, }); if (!response.ok) { throw new Error((await response.json()).error); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts index 4dca8bc3532..ccc0695b22c 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts @@ -1,11 +1,16 @@ import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../../../../server/lib/logger/system'; -import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError } from '../getWorkspaceAccessToken'; +import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/supportedVersionsToken'; import { announcementSync } from './announcementSync'; import { legacySyncWorkspace } from './legacySyncWorkspace'; import { syncCloudData } from './syncCloudData'; +/** + * Syncs the workspace with the cloud + * @returns {Promise} + * @throws {Error} - If there is an unexpected error during sync like a network error + */ export async function syncWorkspace() { try { await syncCloudData(); @@ -14,14 +19,15 @@ export async function syncWorkspace() { } catch (err) { switch (true) { case err instanceof CloudWorkspaceRegistrationError: - case err instanceof CloudWorkspaceAccessTokenError: - case err instanceof CloudWorkspaceAccessTokenEmptyError: { + case err instanceof CloudWorkspaceAccessTokenError: { // There is no access token, so we can't sync SystemLogger.info('Workspace does not have a valid access token, sync aborted'); break; } default: { - SystemLogger.error({ msg: 'Error during workspace sync', err }); + if (!(err instanceof CloudWorkspaceAccessTokenEmptyError) && !isAbortError(err)) { + SystemLogger.error({ msg: 'Error during workspace sync', err }); + } SystemLogger.info({ msg: 'Falling back to legacy sync', function: 'syncCloudData', @@ -32,13 +38,14 @@ export async function syncWorkspace() { } catch (err) { switch (true) { case err instanceof CloudWorkspaceRegistrationError: - case err instanceof CloudWorkspaceAccessTokenError: - case err instanceof CloudWorkspaceAccessTokenEmptyError: { + case err instanceof CloudWorkspaceAccessTokenError: { // There is no access token, so we can't sync break; } default: { - SystemLogger.error({ msg: 'Error during fallback workspace sync', err }); + if (!(err instanceof CloudWorkspaceAccessTokenEmptyError) && !isAbortError(err)) { + SystemLogger.error({ msg: 'Error during fallback workspace sync', err }); + } throw err; } } diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts index bde316fae82..2bff8e1526d 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts @@ -5,7 +5,6 @@ import { v, compile } from 'suretype'; import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; -import { SystemLogger } from '../../../../../server/lib/logger/system'; import { settings } from '../../../../settings/server'; import type { WorkspaceRegistrationData } from '../buildRegistrationData'; import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; @@ -99,6 +98,7 @@ const fetchWorkspaceClientPayload = async ({ Authorization: `Bearer ${token}`, }, body: workspaceRegistrationData, + timeout: 3000, }); if (!response.ok) { @@ -145,37 +145,25 @@ const consumeWorkspaceSyncPayload = async (result: Serialized { } } - setImmediate(() => syncWorkspace()); + setImmediate(async () => { + try { + await syncWorkspace(); + } catch (e: any) { + if (e instanceof CloudWorkspaceAccessTokenEmptyError) { + return; + } + if (e.type && e.type === 'AbortError') { + return; + } + SystemLogger.error('An error occurred syncing workspace.', e.message); + } + }); await cronJobs.add(licenseCronName, '0 */12 * * *', async () => { - await syncWorkspace(); + try { + await syncWorkspace(); + } catch (e: any) { + if (e instanceof CloudWorkspaceAccessTokenEmptyError) { + return; + } + if (e.type && e.type === 'AbortError') { + return; + } + SystemLogger.error('An error occurred syncing workspace.', e.message); + } }); }); diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionCalloutLimits.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionCalloutLimits.tsx index e408d5e85b7..074e349081b 100644 --- a/apps/meteor/client/views/admin/subscription/SubscriptionCalloutLimits.tsx +++ b/apps/meteor/client/views/admin/subscription/SubscriptionCalloutLimits.tsx @@ -42,6 +42,10 @@ export const SubscriptionCalloutLimits = () => { return undefined; } + if (rule.max === -1) { + return undefined; + } + return [key, rule.behavior]; }) .filter(Boolean) as Array<[keyof typeof limits, LicenseBehavior]>; diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx index d0dd1ec0be4..8cda1c28dc1 100644 --- a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx +++ b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx @@ -1,4 +1,4 @@ -import { Box, Button, ButtonGroup, Callout, Grid, Throbber } from '@rocket.chat/fuselage'; +import { Accordion, Box, Button, ButtonGroup, Callout, Grid, Throbber } from '@rocket.chat/fuselage'; import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; import { useRouter } from '@rocket.chat/ui-contexts'; import { t } from 'i18next'; @@ -107,60 +107,64 @@ const SubscriptionPage = () => { {isLicenseLoading && } {!isLicenseLoading && ( - - - {showLicense && ( - + <> + {showLicense && ( + +
{JSON.stringify(licensesData, null, 2)}
+
+
+ )} + + + + {license && } + {!license && } - )} - - {license && } - {!license && } - - - - - - {seatsLimit.value !== undefined && ( - - {seatsLimit.max !== Infinity ? ( - - ) : ( - - )} + + - )} - - {macLimit.value !== undefined && ( - - {macLimit.max !== Infinity ? ( - - ) : ( - - )} - - )} - {!license && ( - <> - {limits?.marketplaceApps !== undefined && ( - - - - )} - - - + {seatsLimit.value !== undefined && ( + + {seatsLimit.max !== Infinity ? ( + + ) : ( + + )} - - + )} + + {macLimit.value !== undefined && ( + + {macLimit.max !== Infinity ? ( + + ) : ( + + )} - - )} - - - + )} + + {!license && ( + <> + {limits?.marketplaceApps !== undefined && ( + + + + )} + + + + + + + + + )} +
+ +
+ )} diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx index 12dc3b76e16..54da2af94a9 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx @@ -8,7 +8,6 @@ import { useModal, useMediaUrl } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import semver from 'semver'; import { useFormatDate } from '../../../../hooks/useFormatDate'; import { useLicense, useLicenseName } from '../../../../hooks/useLicense'; @@ -19,7 +18,7 @@ import type { VersionActionItem } from './components/VersionCardActionItem'; import VersionCardActionItemList from './components/VersionCardActionItemList'; import { VersionCardSkeleton } from './components/VersionCardSkeleton'; import { VersionTag } from './components/VersionTag'; -import type { VersionStatus } from './components/VersionTag'; +import { getVersionStatus } from './getVersionStatus'; import RegisterWorkspaceModal from './modals/RegisterWorkspaceModal'; const SUPPORT_EXTERNAL_LINK = 'https://go.rocket.chat/i/version-support'; @@ -222,25 +221,3 @@ const decodeBase64 = (b64: string): SupportedVersions | undefined => { return JSON.parse(atob(bodyEncoded)); }; - -const getVersionStatus = ( - serverVersion: string, - versions: SupportedVersions['versions'], -): { label: VersionStatus; expiration: Date | undefined } => { - const coercedServerVersion = String(semver.coerce(serverVersion)); - const highestVersion = versions.reduce((prev, current) => (prev.version > current.version ? prev : current)); - const currentVersionData = versions.find((v) => v.version.includes(coercedServerVersion) || v.version.includes(serverVersion)); - const isSupported = currentVersionData?.version === coercedServerVersion || currentVersionData?.version === serverVersion; - - const versionStatus: { - label: VersionStatus; - expiration: Date | undefined; - } = { - label: 'outdated', - ...(semver.gte(coercedServerVersion, highestVersion.version) && { label: 'latest' }), - ...(isSupported && semver.gt(highestVersion.version, coercedServerVersion) && { label: 'available_version' }), - expiration: currentVersionData?.expiration, - }; - - return versionStatus; -}; diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.spec.ts b/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.spec.ts new file mode 100644 index 00000000000..40b3c5753c2 --- /dev/null +++ b/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.spec.ts @@ -0,0 +1,110 @@ +import { getVersionStatus } from './getVersionStatus'; + +describe('if the server version from server and the highest version from cloud are the same', () => { + describe('the expiration date is in the future', () => { + it('should return as latest version', () => { + const status = getVersionStatus('3.0.0', [ + { + version: '3.0.0', + expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), + security: false, + infoUrl: '', + }, + ]); + + expect(status.label).toBe('latest'); + }); + }); + + describe('the expiration date is in the past', () => { + it('should return as outdated version', () => { + const status = getVersionStatus('3.0.0', [ + { + version: '3.0.0', + expiration: new Date('2020-01-01'), + security: false, + infoUrl: '', + }, + ]); + + expect(status.label).toBe('outdated'); + }); + }); +}); + +describe('if the server version is not in the list of supported versions', () => { + it('should return as outdated version', () => { + const status = getVersionStatus('2.0.0', [ + { + version: '3.0.0', + expiration: new Date(), + security: false, + infoUrl: '', + }, + ]); + + expect(status.label).toBe('outdated'); + }); +}); + +describe('if the server version is in the list of supported versions but is not the highest', () => { + describe('the expiration date is in the future', () => { + it('should return as available version', () => { + const status = getVersionStatus('3.0.0', [ + { + version: '3.0.0', + expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), + security: false, + infoUrl: '', + }, + { + version: '4.0.0', + expiration: new Date(), + security: false, + infoUrl: '', + }, + ]); + expect(status.label).toBe('available_version'); + }); + }); + describe('the expiration date is in the past', () => { + it('should return as outdated version', () => { + const status = getVersionStatus('3.0.0', [ + { + version: '3.0.0', + expiration: new Date('2020-01-01'), + security: false, + infoUrl: '', + }, + { + version: '4.0.0', + expiration: new Date(), + security: false, + infoUrl: '', + }, + ]); + expect(status.label).toBe('outdated'); + }); + }); +}); + +describe('if the server version is not in the list of supported versions but is the highest', () => { + it('should return as latest version', () => { + const status = getVersionStatus('4.0.0', [ + { + version: '2.0.0', + expiration: new Date(), + security: false, + infoUrl: '', + }, + { + version: '3.0.0', + expiration: new Date(), + security: false, + infoUrl: '', + }, + ]); + + expect(status.label).toBe('outdated'); + }); +}); diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.ts b/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.ts new file mode 100644 index 00000000000..7216fac607e --- /dev/null +++ b/apps/meteor/client/views/admin/workspace/VersionCard/getVersionStatus.ts @@ -0,0 +1,29 @@ +import type { SupportedVersions } from '@rocket.chat/server-cloud-communication'; +import semver from 'semver'; + +import type { VersionStatus } from './components/VersionTag'; + +export const getVersionStatus = ( + serverVersion: string, + versions: SupportedVersions['versions'], +): { label: VersionStatus; expiration: Date | undefined } => { + const coercedServerVersion = String(semver.coerce(serverVersion)); + const highestVersion = versions.reduce((prev, current) => (prev.version > current.version ? prev : current)); + const currentVersionData = versions.find((v) => v.version.includes(coercedServerVersion) || v.version.includes(serverVersion)); + const currentVersionIsExpired = currentVersionData?.expiration && new Date(currentVersionData.expiration) < new Date(); + + const isSupported = + !currentVersionIsExpired && (currentVersionData?.version === coercedServerVersion || currentVersionData?.version === serverVersion); + + const versionStatus: { + label: VersionStatus; + expiration: Date | undefined; + } = { + label: 'outdated', + ...(isSupported && semver.gte(coercedServerVersion, highestVersion.version) && { label: 'latest' }), + ...(isSupported && semver.gt(highestVersion.version, coercedServerVersion) && { label: 'available_version' }), + expiration: currentVersionData?.expiration, + }; + + return versionStatus; +}; diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index 1078fb092df..f18048de00a 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -83,7 +83,11 @@ const syncByTrigger = async (contexts: string[]) => { }), ); - await syncWorkspace(); + try { + await syncWorkspace(); + } catch (error) { + console.error(error); + } }; // When settings are loaded, apply the current license if there is one. diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 6e84aec4ab7..b9a5857e215 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5958,8 +5958,8 @@ "onboarding.form.standaloneServerForm.servicesUnavailable": "Some of the services will be unavailable or will require manual setup", "onboarding.form.standaloneServerForm.publishOwnApp": "In order to send push notitications you need to compile and publish your own app to Google Play and App Store", "onboarding.form.standaloneServerForm.manuallyIntegrate": "Need to manually integrate with external services", - "subscription.callout.servicesDisruptionsMayOccur": "Services Disruptions may occur", - "subscription.callout.servicesDisruptionsOccurring": "Services Disruptions occurring", + "subscription.callout.servicesDisruptionsMayOccur": "Services disruptions may occur", + "subscription.callout.servicesDisruptionsOccurring": "Services disruptions occurring", "subscription.callout.capabilitiesDisabled": "Capabilities disabled", "subscription.callout.description.limitsExceeded_one": "Your workspace exceeded the <1>{{val}} license limit. <3>Manage your subscription to increase limits.", "subscription.callout.description.limitsExceeded_other": "Your workspace exceeded the <1>{{val, list}} license limits. <3>Manage your subscription to increase limits.", diff --git a/ee/packages/license/src/validation/logKind.ts b/ee/packages/license/src/validation/logKind.ts new file mode 100644 index 00000000000..99a7c9d6d41 --- /dev/null +++ b/ee/packages/license/src/validation/logKind.ts @@ -0,0 +1,11 @@ +import type { LicenseBehavior } from '../definition/LicenseBehavior'; + +export const logKind = (behavior: LicenseBehavior) => { + switch (behavior) { + case 'prevent_installation': + case 'invalidate_license': + return 'error'; + default: + return 'info'; + } +}; diff --git a/ee/packages/license/src/validation/validateLimits.ts b/ee/packages/license/src/validation/validateLimits.ts index d90f5e5f014..1cae603716b 100644 --- a/ee/packages/license/src/validation/validateLimits.ts +++ b/ee/packages/license/src/validation/validateLimits.ts @@ -6,6 +6,7 @@ import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { getCurrentValueForLicenseLimit } from './getCurrentValueForLicenseLimit'; import { getResultingBehavior } from './getResultingBehavior'; +import { logKind } from './logKind'; import { validateLimit } from './validateLimit'; export async function validateLimits( @@ -30,11 +31,22 @@ export async function validateLimits( .filter(({ max, behavior }) => validateLimit(max, currentValue, behavior, extraCount)) .map((limit) => { if (!options.suppressLog) { - logger.error({ - msg: 'Limit validation failed', - kind: limitKey, - limit, - }); + switch (logKind(limit.behavior)) { + case 'error': + logger.error({ + msg: 'Limit validation failed', + kind: limitKey, + limit, + }); + break; + case 'info': + logger.info({ + msg: 'Limit validation failed', + kind: limitKey, + limit, + }); + break; + } } return getResultingBehavior(limit, { reason: 'limit', limit: limitKey }); });