diff --git a/apps/meteor/app/apps/client/@types/IOrchestrator.ts b/apps/meteor/app/apps/client/@types/IOrchestrator.ts new file mode 100644 index 000000000000..f178cd03960d --- /dev/null +++ b/apps/meteor/app/apps/client/@types/IOrchestrator.ts @@ -0,0 +1,204 @@ +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata/IAppInfo'; +import { ISetting } from '@rocket.chat/apps-engine/definition/settings/ISetting'; + +export interface IDetailedDescription { + raw: string; + rendered: string; +} + +export interface IDetailedChangelog { + raw: string; + rendered: string; +} + +export interface IAuthor { + name: string; + support: string; + homepage: string; +} + +export interface ILicense { + license: string; + version: number; + expireDate: Date; +} + +export interface ISubscriptionInfo { + typeOf: string; + status: string; + statusFromBilling: boolean; + isSeatBased: boolean; + seats: number; + maxSeats: number; + license: ILicense; + startDate: Date; + periodEnd: Date; + endDate: Date; + externallyManaged: boolean; + isSubscribedViaBundle: boolean; +} + +export interface IPermission { + name: string; + scopes: string[]; +} + +export interface ILatest { + internalId: string; + id: string; + name: string; + nameSlug: string; + version: string; + categories: string[]; + description: string; + detailedDescription: IDetailedDescription; + detailedChangelog: IDetailedChangelog; + requiredApiVersion: string; + author: IAuthor; + classFile: string; + iconFile: string; + iconFileData: string; + status: string; + isVisible: boolean; + createdDate: Date; + modifiedDate: Date; + isPurchased: boolean; + isSubscribed: boolean; + subscriptionInfo: ISubscriptionInfo; + compiled: boolean; + compileJobId: string; + changesNote: string; + languages: string[]; + privacyPolicySummary: string; + internalChangesNote: string; + permissions: IPermission[]; +} + +export interface IBundledIn { + bundleId: string; + bundleName: string; + addonTierId: string; +} + +export interface IILicense { + license: string; + version: number; + expireDate: Date; +} + +export interface ITier { + perUnit: boolean; + minimum: number; + maximum: number; + price: number; + refId: string; +} + +export interface IPricingPlan { + id: string; + enabled: boolean; + price: number; + trialDays: number; + strategy: string; + isPerSeat: boolean; + tiers: ITier[]; +} + +export enum EAppPurchaseType { + PurchaseTypeEmpty = '', + PurchaseTypeBuy = 'buy', + PurchaseTypeSubscription = 'subscription', +} + +export interface IAppFromMarketplace { + appId: string; + latest: ILatest; + isAddon: boolean; + isEnterpriseOnly: boolean; + isBundle: boolean; + bundledAppIds: any[]; + bundledIn: IBundledIn[]; + isPurchased: boolean; + isSubscribed: boolean; + subscriptionInfo: ISubscriptionInfo; + price: number; + purchaseType: EAppPurchaseType; + isUsageBased: boolean; + createdAt: Date; + modifiedAt: Date; + pricingPlans: IPricingPlan[]; + addonId: string; +} + +export interface ILanguageInfo { + Params: string; + Description: string; + Setting_Name: string; + Setting_Description: string; +} + +export interface ILanguages { + [key: string]: ILanguageInfo; +} + +export interface IAppLanguage { + id: string; + languages: ILanguages; +} + +export interface IAppExternalURL { + url: string; + success: boolean; +} + +export interface ICategory { + createdDate: Date; + description: string; + id: string; + modifiedDate: Date; + title: string; +} + +export interface IDeletedInstalledApp { + app: IAppInfo; + success: boolean; +} + +export interface IAppSynced { + app: IAppFromMarketplace; + success: boolean; +} + +export interface IScreenshot { + id: string; + appId: string; + fileName: string; + altText: string; + accessUrl: string; + thumbnailUrl: string; + createdAt: Date; + modifiedAt: Date; +} + +export interface IAppScreenshots { + screenshots: IScreenshot[]; + success: boolean; +} + +export interface ISettings { + [key: string]: ISetting; +} + +export interface ISettingsReturn { + settings: ISettings; + success: boolean; +} + +export interface ISettingsPayload { + settings: ISetting[]; +} + +export interface ISettingsSetReturn { + updated: ISettings; + success: boolean; +} diff --git a/apps/meteor/app/apps/client/orchestrator.js b/apps/meteor/app/apps/client/orchestrator.js deleted file mode 100644 index b9194bf5d83e..000000000000 --- a/apps/meteor/app/apps/client/orchestrator.js +++ /dev/null @@ -1,204 +0,0 @@ -import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { hasAtLeastOnePermission } from '../../authorization'; -import { settings } from '../../settings/client'; -import { CachedCollectionManager } from '../../ui-cached-collection'; -import { APIClient } from '../../utils'; -import { AppWebsocketReceiver } from './communication'; -import { handleI18nResources } from './i18n'; -import { RealAppsEngineUIHost } from './RealAppsEngineUIHost'; - -const createDeferredValue = () => { - let resolve; - let reject; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - return [promise, resolve, reject]; -}; - -class AppClientOrchestrator { - constructor() { - this._appClientUIHost = new RealAppsEngineUIHost(); - this._manager = new AppClientManager(this._appClientUIHost); - this.isLoaded = false; - [this.deferredIsEnabled, this.setEnabled] = createDeferredValue(); - } - - load = async (isEnabled) => { - if (!this.isLoaded) { - this.ws = new AppWebsocketReceiver(); - this.isLoaded = true; - } - - this.setEnabled(isEnabled); - - // Since the deferred value (a promise) is immutable after resolved, - // it need to be recreated to resolve a new value - [this.deferredIsEnabled, this.setEnabled] = createDeferredValue(); - - await handleI18nResources(); - this.setEnabled(isEnabled); - }; - - getWsListener = () => this.ws; - - getAppClientManager = () => this._manager; - - handleError = (error) => { - console.error(error); - if (hasAtLeastOnePermission(['manage-apps'])) { - dispatchToastMessage({ - type: 'error', - message: error.message, - }); - } - }; - - isEnabled = () => this.deferredIsEnabled; - - getApps = async () => { - const { apps } = await APIClient.get('apps'); - return apps; - }; - - getAppsFromMarketplace = async () => { - const appsOverviews = await APIClient.get('apps', { marketplace: 'true' }); - return appsOverviews.map(({ latest, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt }) => ({ - ...latest, - price, - pricingPlans, - purchaseType, - isEnterpriseOnly, - modifiedAt, - })); - }; - - getAppsOnBundle = async (bundleId) => { - const { apps } = await APIClient.get(`apps/bundles/${bundleId}/apps`); - return apps; - }; - - getAppsLanguages = async () => { - const { apps } = await APIClient.get('apps/languages'); - return apps; - }; - - getApp = async (appId) => { - const { app } = await APIClient.get(`apps/${appId}`); - return app; - }; - - getAppFromMarketplace = async (appId, version) => { - const { app } = await APIClient.get(`apps/${appId}`, { - marketplace: 'true', - version, - }); - return app; - }; - - getLatestAppFromMarketplace = async (appId, version) => { - const { app } = await APIClient.get(`apps/${appId}`, { - marketplace: 'true', - update: 'true', - appVersion: version, - }); - return app; - }; - - getAppSettings = async (appId) => { - const { settings } = await APIClient.get(`apps/${appId}/settings`); - return settings; - }; - - setAppSettings = async (appId, settings) => { - const { updated } = await APIClient.post(`apps/${appId}/settings`, undefined, { settings }); - return updated; - }; - - getAppApis = async (appId) => { - const { apis } = await APIClient.get(`apps/${appId}/apis`); - return apis; - }; - - getAppLanguages = async (appId) => { - const { languages } = await APIClient.get(`apps/${appId}/languages`); - return languages; - }; - - installApp = async (appId, version, permissionsGranted) => { - const { app } = await APIClient.post('apps/', { - appId, - marketplace: true, - version, - permissionsGranted, - }); - return app; - }; - - updateApp = async (appId, version, permissionsGranted) => { - const { app } = await APIClient.post(`apps/${appId}`, { - appId, - marketplace: true, - version, - permissionsGranted, - }); - return app; - }; - - uninstallApp = (appId) => APIClient.delete(`apps/${appId}`); - - syncApp = (appId) => APIClient.post(`apps/${appId}/sync`); - - setAppStatus = async (appId, status) => { - const { status: effectiveStatus } = await APIClient.post(`apps/${appId}/status`, { status }); - return effectiveStatus; - }; - - screenshots = (appId) => APIClient.get(`apps/${appId}/screenshots`); - - enableApp = (appId) => this.setAppStatus(appId, 'manually_enabled'); - - disableApp = (appId) => this.setAppStatus(appId, 'manually_disabled'); - - buildExternalUrl = (appId, purchaseType = 'buy', details = false) => - APIClient.get('apps', { - buildExternalUrl: 'true', - appId, - purchaseType, - details, - }); - - getCategories = async () => { - const categories = await APIClient.get('apps', { categories: 'true' }); - return categories; - }; - - getUIHost = () => this._appClientUIHost; -} - -export const Apps = new AppClientOrchestrator(); - -Meteor.startup(() => { - CachedCollectionManager.onLogin(() => { - Meteor.call('apps/is-enabled', (error, isEnabled) => { - if (error) { - Apps.handleError(error); - return; - } - - Apps.getAppClientManager().initialize(); - Apps.load(isEnabled); - }); - }); - - Tracker.autorun(() => { - const isEnabled = settings.get('Apps_Framework_enabled'); - Apps.load(isEnabled); - }); -}); diff --git a/apps/meteor/app/apps/client/orchestrator.ts b/apps/meteor/app/apps/client/orchestrator.ts new file mode 100644 index 000000000000..ecbe4c9ed818 --- /dev/null +++ b/apps/meteor/app/apps/client/orchestrator.ts @@ -0,0 +1,285 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; +import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage/IAppStorageItem'; +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { App } from '../../../client/views/admin/apps/types'; +import { dispatchToastMessage } from '../../../client/lib/toast'; +import { settings } from '../../settings/client'; +import { CachedCollectionManager } from '../../ui-cached-collection'; +import { createDeferredValue } from '../lib/misc/DeferredValue'; +import { + IPricingPlan, + EAppPurchaseType, + IAppFromMarketplace, + IAppLanguage, + IAppExternalURL, + ICategory, + IDeletedInstalledApp, + IAppSynced, + IAppScreenshots, + IAuthor, + IDetailedChangelog, + IDetailedDescription, + ISubscriptionInfo, + ISettingsReturn, + ISettingsPayload, + ISettingsSetReturn, +} from './@types/IOrchestrator'; +import { AppWebsocketReceiver } from './communication'; +import { handleI18nResources } from './i18n'; +import { RealAppsEngineUIHost } from './RealAppsEngineUIHost'; + +const { APIClient } = require('../../utils'); +const { hasAtLeastOnePermission } = require('../../authorization'); + +export interface IAppsFromMarketplace { + price: number; + pricingPlans: IPricingPlan[]; + purchaseType: EAppPurchaseType; + isEnterpriseOnly: boolean; + modifiedAt: Date; + internalId: string; + id: string; + name: string; + nameSlug: string; + version: string; + categories: string[]; + description: string; + detailedDescription: IDetailedDescription; + detailedChangelog: IDetailedChangelog; + requiredApiVersion: string; + author: IAuthor; + classFile: string; + iconFile: string; + iconFileData: string; + status: string; + isVisible: boolean; + createdDate: Date; + modifiedDate: Date; + isPurchased: boolean; + isSubscribed: boolean; + subscriptionInfo: ISubscriptionInfo; + compiled: boolean; + compileJobId: string; + changesNote: string; + languages: string[]; + privacyPolicySummary: string; + internalChangesNote: string; + permissions: IPermission[]; +} + +class AppClientOrchestrator { + private _appClientUIHost: RealAppsEngineUIHost; + + private _manager: AppClientManager; + + private isLoaded: boolean; + + private ws: AppWebsocketReceiver; + + private setEnabled: (value: boolean | PromiseLike) => void; + + private deferredIsEnabled: Promise | undefined; + + constructor() { + this._appClientUIHost = new RealAppsEngineUIHost(); + this._manager = new AppClientManager(this._appClientUIHost); + this.isLoaded = false; + const { promise, resolve } = createDeferredValue(); + this.deferredIsEnabled = promise; + this.setEnabled = resolve; + } + + public async load(isEnabled: boolean): Promise { + if (!this.isLoaded) { + this.ws = new AppWebsocketReceiver(); + this.isLoaded = true; + } + + await handleI18nResources(); + + this.setEnabled(isEnabled); + } + + public getWsListener(): AppWebsocketReceiver { + return this.ws; + } + + public getAppClientManager(): AppClientManager { + return this._manager; + } + + public handleError(error: Error): void { + if (hasAtLeastOnePermission(['manage-apps'])) { + dispatchToastMessage({ + type: 'error', + message: error.message, + }); + } + } + + public screenshots(appId: string): IAppScreenshots { + return APIClient.get(`apps/${appId}/screenshots`); + } + + public isEnabled(): Promise | undefined { + return this.deferredIsEnabled; + } + + public async getApps(): Promise { + const { apps } = await APIClient.get('apps'); + return apps; + } + + public async getAppsFromMarketplace(): Promise { + const appsOverviews: IAppFromMarketplace[] = await APIClient.get('apps', { marketplace: 'true' }); + return appsOverviews.map((app: IAppFromMarketplace) => { + const { latest, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt } = app; + return { + ...latest, + price, + pricingPlans, + purchaseType, + isEnterpriseOnly, + modifiedAt, + }; + }); + } + + public async getAppsOnBundle(bundleId: string): Promise { + const { apps } = await APIClient.get(`apps/bundles/${bundleId}/apps`); + return apps; + } + + public async getAppsLanguages(): Promise { + const { apps } = await APIClient.get('apps/languages'); + return apps; + } + + public async getApp(appId: string): Promise { + const { app } = await APIClient.get(`apps/${appId}`); + return app; + } + + public async getAppFromMarketplace(appId: string, version: string): Promise { + const { app } = await APIClient.get(`apps/${appId}`, { + marketplace: 'true', + version, + }); + return app; + } + + public async getLatestAppFromMarketplace(appId: string, version: string): Promise { + const { app } = await APIClient.get(`apps/${appId}`, { + marketplace: 'true', + update: 'true', + appVersion: version, + }); + return app; + } + + public async getAppSettings(appId: string): Promise { + const { settings } = await APIClient.get(`apps/${appId}/settings`); + return settings; + } + + public async setAppSettings(appId: string, settings: ISettingsPayload): Promise { + const { updated } = await APIClient.post(`apps/${appId}/settings`, undefined, { settings }); + return updated; + } + + public async getAppApis(appId: string): Promise { + const { apis } = await APIClient.get(`apps/${appId}/apis`); + return apis; + } + + public async getAppLanguages(appId: string): Promise { + const { languages } = await APIClient.get(`apps/${appId}/languages`); + return languages; + } + + public async installApp(appId: string, version: string, permissionsGranted: IPermission[]): Promise { + const { app } = await APIClient.post('apps/', { + appId, + marketplace: true, + version, + permissionsGranted, + }); + return app; + } + + public async updateApp(appId: string, version: string, permissionsGranted: IPermission[]): Promise { + const { app } = await APIClient.post(`apps/${appId}`, { + appId, + marketplace: true, + version, + permissionsGranted, + }); + return app; + } + + public uninstallApp(appId: string): IDeletedInstalledApp { + return APIClient.delete(`apps/${appId}`); + } + + public syncApp(appId: string): IAppSynced { + return APIClient.post(`apps/${appId}/sync`); + } + + public async setAppStatus(appId: string, status: AppStatus): Promise { + const { status: effectiveStatus } = await APIClient.post(`apps/${appId}/status`, { status }); + return effectiveStatus; + } + + public enableApp(appId: string): Promise { + return this.setAppStatus(appId, AppStatus.MANUALLY_ENABLED); + } + + public disableApp(appId: string): Promise { + return this.setAppStatus(appId, AppStatus.MANUALLY_ENABLED); + } + + public buildExternalUrl(appId: string, purchaseType = 'buy', details = false): IAppExternalURL { + return APIClient.get('apps', { + buildExternalUrl: 'true', + appId, + purchaseType, + details, + }); + } + + public async getCategories(): Promise { + const categories = await APIClient.get('apps', { categories: 'true' }); + return categories; + } + + public getUIHost(): RealAppsEngineUIHost { + return this._appClientUIHost; + } +} + +export const Apps = new AppClientOrchestrator(); + +Meteor.startup(() => { + CachedCollectionManager.onLogin(() => { + Meteor.call('apps/is-enabled', (error: Error, isEnabled: boolean) => { + if (error) { + Apps.handleError(error); + return; + } + + Apps.getAppClientManager().initialize(); + Apps.load(isEnabled); + }); + }); + + Tracker.autorun(() => { + const isEnabled = settings.get('Apps_Framework_enabled'); + Apps.load(isEnabled); + }); +}); diff --git a/apps/meteor/app/apps/lib/misc/DeferredValue.ts b/apps/meteor/app/apps/lib/misc/DeferredValue.ts new file mode 100644 index 000000000000..6089920024c1 --- /dev/null +++ b/apps/meteor/app/apps/lib/misc/DeferredValue.ts @@ -0,0 +1,33 @@ +export type ResolveHandler = (value: T | PromiseLike) => void; +export type RejectHandler = (reason: unknown) => void; + +class Deferred { + promise: Promise; + + resolve!: ResolveHandler; + + reject!: RejectHandler; + + constructor() { + this.promise = new Promise((_resolve, _reject) => { + this.resolve = _resolve; + this.reject = _reject; + }); + } + + get computedPromise(): Promise { + return this.promise; + } + + get computedResolve(): ResolveHandler { + return this.resolve; + } + + get computedReject(): RejectHandler { + return this.reject; + } +} + +const createDeferredValue = (): Deferred => new Deferred(); + +export { createDeferredValue }; diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx b/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx index 920efb1373c4..afee3b3411cc 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx @@ -3,6 +3,7 @@ import { Button, ButtonGroup, Icon, Box, Throbber } from '@rocket.chat/fuselage' import { useRoute, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useCallback, useRef, FC } from 'react'; +import { ISettingsPayload } from '../../../../app/apps/client/@types/IOrchestrator'; import { Apps } from '../../../../app/apps/client/orchestrator'; import Page from '../../../components/Page'; import APIsDisplay from './APIsDisplay'; @@ -39,7 +40,7 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { try { await Apps.setAppSettings( id, - Object.values(settings).map((value) => ({ ...value, value: current?.[value.id] })), + (Object.values(settings) as ISetting[]).map((value) => ({ ...value, value: current?.[value.id] })) as unknown as ISettingsPayload, ); } catch (e) { handleAPIError(e); diff --git a/apps/meteor/client/views/admin/apps/AppsProvider.tsx b/apps/meteor/client/views/admin/apps/AppsProvider.tsx index 1e3cec58e8f6..b8de1c740484 100644 --- a/apps/meteor/client/views/admin/apps/AppsProvider.tsx +++ b/apps/meteor/client/views/admin/apps/AppsProvider.tsx @@ -162,7 +162,7 @@ const AppsProvider: FC = ({ children }) => { let installedAppsError = false; try { - marketplaceApps = (await Apps.getAppsFromMarketplace()) as App[]; + marketplaceApps = (await Apps.getAppsFromMarketplace()) as unknown as App[]; } catch (e) { dispatchMarketplaceApps({ type: 'failure', diff --git a/apps/meteor/client/views/admin/apps/definitions/AppInfo.ts b/apps/meteor/client/views/admin/apps/definitions/AppInfo.ts index a87079a12854..68cf75a214d7 100644 --- a/apps/meteor/client/views/admin/apps/definitions/AppInfo.ts +++ b/apps/meteor/client/views/admin/apps/definitions/AppInfo.ts @@ -1,9 +1,9 @@ import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; -import { AppSettingsManager } from '@rocket.chat/apps-engine/server/managers/AppSettingsManager'; +import { ISettingsReturn } from '../../../../../app/apps/client/@types/IOrchestrator'; import { App } from '../types'; export type AppInfo = App & { - settings: ReturnType; + settings?: ISettingsReturn; apis: Array; }; diff --git a/apps/meteor/client/views/admin/apps/hooks/useAppInfo.ts b/apps/meteor/client/views/admin/apps/hooks/useAppInfo.ts index 31ce44537b09..4a64705cc4d4 100644 --- a/apps/meteor/client/views/admin/apps/hooks/useAppInfo.ts +++ b/apps/meteor/client/views/admin/apps/hooks/useAppInfo.ts @@ -1,7 +1,7 @@ import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; -import { AppSettingsManager } from '@rocket.chat/apps-engine/server/managers/AppSettingsManager'; import { useState, useEffect, useContext } from 'react'; +import { ISettingsReturn } from '../../../../../app/apps/client/@types/IOrchestrator'; import { Apps } from '../../../../../app/apps/client/orchestrator'; import { AppsContext } from '../AppsContext'; import { AppInfo } from '../definitions/AppInfo'; @@ -28,20 +28,19 @@ const getBundledIn = async (appId: string, appVersion: string): Promise> => { +const getSettings = async (appId: string, installed?: boolean): Promise => { if (!installed) { - return {}; + return; } try { return Apps.getAppSettings(appId); } catch (e) { handleAPIError(e); - return {}; } }; -const getApis = async (appId: string, installed: boolean): Promise> => { +const getApis = async (appId: string, installed?: boolean): Promise> => { if (!installed) { return []; } diff --git a/apps/meteor/client/views/admin/apps/hooks/useCategories.ts b/apps/meteor/client/views/admin/apps/hooks/useCategories.ts index 172d7facebe4..075cc139f8ca 100644 --- a/apps/meteor/client/views/admin/apps/hooks/useCategories.ts +++ b/apps/meteor/client/views/admin/apps/hooks/useCategories.ts @@ -1,20 +1,13 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ICategory } from '../../../../../app/apps/client/@types/IOrchestrator'; import { Apps } from '../../../../../app/apps/client/orchestrator'; import { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions'; import { handleAPIError } from '../helpers'; import { useCategoryFlatList } from './useCategoryFlatList'; import { useCategoryToggle } from './useCategoryToggle'; -type Category = { - id: string; - title: string; - description: string; - createdDate: string; - modifiedDate: string; -}; - export const useCategories = (): [ CategoryDropDownListProps['groups'], (CategoryDropdownItem & { checked: true })[], @@ -28,7 +21,7 @@ export const useCategories = (): [ try { const fetchedCategories = await Apps.getCategories(); - const mappedCategories = fetchedCategories.map((currentCategory: Category) => ({ + const mappedCategories = fetchedCategories.map((currentCategory: ICategory) => ({ id: currentCategory.id, label: currentCategory.title, checked: false,