diff --git a/apps/meteor/client/views/marketplace/AccordionLoading.tsx b/apps/meteor/client/views/marketplace/AccordionLoading.tsx deleted file mode 100644 index 5591baf268d8..000000000000 --- a/apps/meteor/client/views/marketplace/AccordionLoading.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Box, Skeleton, Margins } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; -import React from 'react'; - -const AccordionLoading: FC = () => ( - - - - - - - -); - -export default AccordionLoading; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx index 11fada9305e9..a653a22478a8 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx @@ -4,7 +4,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime'; -import AccordionLoading from '../../../AccordionLoading'; +import AccordionLoading from '../../../components/AccordionLoading'; import { useLogs } from '../../../hooks/useLogs'; import AppLogsItem from './AppLogsItem'; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleases.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleases.tsx index 96d04a134893..cf8118f2a766 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleases.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleases.tsx @@ -5,10 +5,9 @@ import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React from 'react'; -import AccordionLoading from '../../../AccordionLoading'; +import AccordionLoading from '../../../components/AccordionLoading'; import AppReleasesItem from './AppReleasesItem'; -// TODO: replace useEndpointData const AppReleases = ({ id }: { id: App['id'] }): ReactElement => { const getVersions = useEndpoint('GET', '/apps/:id/versions', { id }); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/apps/meteor/client/views/marketplace/AppInstallPage.js b/apps/meteor/client/views/marketplace/AppInstallPage.js deleted file mode 100644 index 3355fd69896f..000000000000 --- a/apps/meteor/client/views/marketplace/AppInstallPage.js +++ /dev/null @@ -1,250 +0,0 @@ -import { Button, ButtonGroup, Icon, Field, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; -import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { - useSetModal, - useEndpoint, - useUpload, - useTranslation, - useRouteParameter, - useRouter, - useSearchParameter, -} from '@rocket.chat/ui-contexts'; -import React, { useCallback, useState } from 'react'; -import { useForm, Controller } from 'react-hook-form'; - -import { AppClientOrchestratorInstance } from '../../../ee/client/apps/orchestrator'; -import { Page, PageHeader, PageScrollableContent } from '../../components/Page'; -import { useAppsReload } from '../../contexts/hooks/useAppsReload'; -import { useExternalLink } from '../../hooks/useExternalLink'; -import { useSingleFileInput } from '../../hooks/useSingleFileInput'; -import { useCheckoutUrl } from '../admin/subscription/hooks/useCheckoutUrl'; -import AppPermissionsReviewModal from './AppPermissionsReviewModal'; -import AppUpdateModal from './AppUpdateModal'; -import AppInstallModal from './components/AppInstallModal/AppInstallModal'; -import { handleAPIError } from './helpers/handleAPIError'; -import { handleInstallError } from './helpers/handleInstallError'; -import { useAppsCountQuery } from './hooks/useAppsCountQuery'; -import { getManifestFromZippedApp } from './lib/getManifestFromZippedApp'; - -const placeholderUrl = 'https://rocket.chat/apps/package.zip'; - -function AppInstallPage() { - const t = useTranslation(); - - const reload = useAppsReload(); - - const router = useRouter(); - - const context = useRouteParameter('context'); - - const setModal = useSetModal(); - - const appId = useSearchParameter('id'); - const queryUrl = useSearchParameter('url'); - - const [installing, setInstalling] = useState(false); - - const endpointAddress = appId ? `/apps/${appId}` : '/apps'; - const downloadApp = useEndpoint('POST', endpointAddress); - const uploadAppEndpoint = useUpload(endpointAddress); - const uploadUpdateApp = useUpload(`${endpointAddress}/update`); - - const appCountQuery = useAppsCountQuery('private'); - - const openExternalLink = useExternalLink(); - const manageSubscriptionUrl = useCheckoutUrl()({ target: 'marketplace-app-install', action: 'Enable_unlimited_apps' }); - - const { control, setValue, watch } = useForm({ defaultValues: { url: queryUrl || '' } }); - const { file, url } = watch(); - - const canSave = !!url || !!file?.name; - - const [handleUploadButtonClick] = useSingleFileInput((value) => setValue('file', value), 'app'); - - const sendFile = async (permissionsGranted, appFile, appId) => { - let app; - const fileData = new FormData(); - fileData.append('app', appFile, appFile.name); - fileData.append('permissions', JSON.stringify(permissionsGranted)); - - try { - if (appId) { - await uploadUpdateApp(fileData); - } else { - app = await uploadAppEndpoint(fileData); - } - - router.navigate({ - name: 'marketplace', - params: { - context: 'private', - page: 'info', - id: appId || app.app.id, - }, - }); - - reload(); - } catch (e) { - handleAPIError(e); - } finally { - setInstalling(false); - setModal(null); - } - }; - - const cancelAction = useCallback(() => { - setInstalling(false); - setModal(null); - }, [setInstalling, setModal]); - - const isAppInstalled = async (appId) => { - try { - const app = await AppClientOrchestratorInstance.getApp(appId); - return !!app || false; - } catch (e) { - return false; - } - }; - - const handleAppPermissionsReview = async (permissions, appFile, appId) => { - setModal( - sendFile(permissionsGranted, appFile, appId)} - />, - ); - }; - - const uploadFile = async (appFile, { id, permissions }) => { - const isInstalled = await isAppInstalled(id); - - if (isInstalled) { - return setModal( handleAppPermissionsReview(permissions, appFile, id)} />); - } - - await handleAppPermissionsReview(permissions, appFile); - }; - - const getAppFileAndManifest = async () => { - try { - let manifest; - let appFile; - if (url) { - const { buff } = await downloadApp({ url, downloadOnly: true }); - const fileData = Uint8Array.from(buff.data); - manifest = await getManifestFromZippedApp(fileData); - appFile = new File([fileData], 'app.zip', { type: 'application/zip' }); - } else { - appFile = file; - manifest = await getManifestFromZippedApp(appFile); - } - - return { appFile, manifest }; - } catch (error) { - handleInstallError(error); - - return { appFile: null, manifest: null }; - } - }; - - const install = async () => { - setInstalling(true); - - if (!appCountQuery.data) { - return cancelAction(); - } - - const { appFile, manifest } = await getAppFileAndManifest(); - - if (!appFile || !manifest) { - return cancelAction(); - } - - if (appCountQuery.data.hasUnlimitedApps) { - return uploadFile(appFile, manifest); - } - - setModal( - uploadFile(appFile, manifest)} - handleEnableUnlimitedApps={() => { - openExternalLink(manageSubscriptionUrl); - setModal(null); - }} - />, - ); - }; - - const handleCancel = () => { - router.navigate({ - name: 'marketplace', - params: { - context, - page: 'list', - }, - }); - }; - - const urlField = useUniqueId(); - const fileField = useUniqueId(); - - return ( - - - - - - {t('App_Url_to_Install_From')} - - ( - } {...field} /> - )} - /> - - - - {t('App_Url_to_Install_From_File')} - - ( - - {t('Browse_Files')} - - } - /> - )} - /> - - - - - - - - - - - - ); -} - -export default AppInstallPage; diff --git a/apps/meteor/client/views/marketplace/AppInstallPage.tsx b/apps/meteor/client/views/marketplace/AppInstallPage.tsx new file mode 100644 index 000000000000..5168f978b4bc --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppInstallPage.tsx @@ -0,0 +1,94 @@ +import { Button, ButtonGroup, Icon, Field, FieldGroup, FieldLabel, FieldRow, TextInput, Callout } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useRouter, useSearchParameter } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import { Page, PageHeader, PageScrollableContent } from '../../components/Page'; +import { useSingleFileInput } from '../../hooks/useSingleFileInput'; +import { useInstallApp } from './hooks/useInstallApp'; + +const PLACEHOLDER_URL = 'https://rocket.chat/apps/package.zip'; + +const AppInstallPage = () => { + const t = useTranslation(); + const router = useRouter(); + + const queryUrl = useSearchParameter('url'); + + const { control, setValue, watch } = useForm<{ file: File; url: string }>({ defaultValues: { url: queryUrl || '' } }); + const { file, url } = watch(); + const { install, isInstalling } = useInstallApp(file, url); + + const [handleUploadButtonClick] = useSingleFileInput((value) => setValue('file', value), 'app'); + + const handleCancel = useCallback(() => { + router.navigate({ + name: 'marketplace', + params: { + context: 'private', + page: 'list', + }, + }); + }, [router]); + + const urlField = useUniqueId(); + const fileField = useUniqueId(); + + return ( + + + + + + {t('App_Url_to_Install_From')} + + {t('App_Installation_Deprecation')} + + + ( + } {...field} /> + )} + /> + + + + {t('App_Url_to_Install_From_File')} + + ( + + {t('Browse_Files')} + + } + /> + )} + /> + + + + + + + + + + + + ); +}; + +export default AppInstallPage; diff --git a/apps/meteor/client/views/marketplace/components/AccordionLoading.tsx b/apps/meteor/client/views/marketplace/components/AccordionLoading.tsx new file mode 100644 index 000000000000..a3c561677a32 --- /dev/null +++ b/apps/meteor/client/views/marketplace/components/AccordionLoading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@rocket.chat/fuselage'; +import React from 'react'; + +const SKELETON_ITEMS = 3; + +const AccordionLoading = () => ( + <> + {Array.from({ length: SKELETON_ITEMS }, (_v, k) => ( + + ))} + +); + +export default AccordionLoading; diff --git a/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx b/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx new file mode 100644 index 000000000000..78a0283ef8a9 --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx @@ -0,0 +1,167 @@ +import type { App, AppPermission } from '@rocket.chat/core-typings'; +import { useRouter, useSetModal, useUpload, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import React, { useCallback, useState } from 'react'; + +import { AppClientOrchestratorInstance } from '../../../../ee/client/apps/orchestrator'; +import { useAppsReload } from '../../../contexts/hooks/useAppsReload'; +import { useExternalLink } from '../../../hooks/useExternalLink'; +import { useCheckoutUrl } from '../../admin/subscription/hooks/useCheckoutUrl'; +import AppPermissionsReviewModal from '../AppPermissionsReviewModal'; +import AppUpdateModal from '../AppUpdateModal'; +import AppInstallationModal from '../components/AppInstallModal/AppInstallModal'; +import { handleAPIError } from '../helpers/handleAPIError'; +import { handleInstallError } from '../helpers/handleInstallError'; +import { getManifestFromZippedApp } from '../lib/getManifestFromZippedApp'; +import { useAppsCountQuery } from './useAppsCountQuery'; + +export const useInstallApp = (file: File, url: string): { install: () => void; isInstalling: boolean } => { + const reloadAppsList = useAppsReload(); + const openExternalLink = useExternalLink(); + const setModal = useSetModal(); + + const router = useRouter(); + + const appCountQuery = useAppsCountQuery('private'); + const manageSubscriptionUrl = useCheckoutUrl()({ target: 'marketplace-app-install', action: 'Enable_unlimited_apps' }); + + const uploadAppEndpoint = useUpload('/apps'); + const uploadUpdateEndpoint = useUpload('/apps/update'); + + // TODO: This function should not be called in a next major version, it will be changed by an endpoint deprecation. + const downloadPrivateAppFromUrl = useEndpoint('POST', '/apps'); + + const [isInstalling, setInstalling] = useState(false); + + const { mutate: sendFile } = useMutation( + ['apps/installPrivateApp'], + ({ permissionsGranted, appFile, appId }: { permissionsGranted: AppPermission[]; appFile: File; appId?: string }) => { + const fileData = new FormData(); + fileData.append('app', appFile, appFile.name); + fileData.append('permissions', JSON.stringify(permissionsGranted)); + + if (appId) { + return uploadUpdateEndpoint(fileData); + } + + return uploadAppEndpoint(fileData) as any; + }, + { + onSuccess: (data: { app: App }) => { + router.navigate({ + name: 'marketplace', + params: { + context: 'private', + page: 'info', + id: data.app.id, + }, + }); + }, + onError: (e) => { + handleAPIError(e); + }, + onSettled: () => { + setInstalling(false); + setModal(null); + reloadAppsList(); + }, + }, + ); + + const cancelAction = useCallback(() => { + setInstalling(false); + setModal(null); + }, [setInstalling, setModal]); + + const isAppInstalled = async (appId: string) => { + try { + const app = await AppClientOrchestratorInstance.getApp(appId); + return !!app || false; + } catch (e) { + return false; + } + }; + + const handleAppPermissionsReview = async (permissions: AppPermission[], appFile: File, appId?: string) => { + setModal( + sendFile({ permissionsGranted, appFile, appId })} + />, + ); + }; + + const uploadFile = async (appFile: File, { id, permissions }: { id: string; permissions: AppPermission[] }) => { + const isInstalled = await isAppInstalled(id); + + if (isInstalled) { + return setModal( handleAppPermissionsReview(permissions, appFile, id)} />); + } + + await handleAppPermissionsReview(permissions, appFile); + }; + + /** @deprecated */ + const getAppFile = async (): Promise => { + try { + const { buff } = (await downloadPrivateAppFromUrl({ url, downloadOnly: true })) as { buff: { data: ArrayLike } }; + + return new File([Uint8Array.from(buff.data)], 'app.zip', { type: 'application/zip' }); + } catch (error) { + handleInstallError(error as Error); + } + }; + + const extractManifestFromAppFile = async (appFile: File) => { + const manifest = await getManifestFromZippedApp(appFile); + return manifest; + }; + + const install = async () => { + let appFile: File | undefined; + + setInstalling(true); + + if (!appCountQuery.data) { + return cancelAction(); + } + + if (!file) { + appFile = await getAppFile(); + } else { + appFile = file; + } + + if (!appFile) { + return cancelAction(); + } + + const manifest = await extractManifestFromAppFile(appFile); + + if (!manifest) { + return cancelAction(); + } + + if (appCountQuery.data.hasUnlimitedApps) { + return uploadFile(appFile, manifest); + } + + setModal( + uploadFile(appFile as File, manifest)} + handleEnableUnlimitedApps={() => { + openExternalLink(manageSubscriptionUrl); + setModal(null); + }} + />, + ); + }; + + return { install, isInstalling }; +}; diff --git a/apps/meteor/client/views/marketplace/lib/getManifestFromZippedApp.ts b/apps/meteor/client/views/marketplace/lib/getManifestFromZippedApp.ts index e9a292de6181..70f0b8827aa1 100644 --- a/apps/meteor/client/views/marketplace/lib/getManifestFromZippedApp.ts +++ b/apps/meteor/client/views/marketplace/lib/getManifestFromZippedApp.ts @@ -1,7 +1,8 @@ +import type { AppPermission } from '@rocket.chat/core-typings'; import { unzipSync, strFromU8 } from 'fflate'; type Uint8ArrayObject = { [fileName: string]: Uint8Array }; -type AppManifestSchema = { [key: string]: string }; +type AppManifestSchema = { id: string; name: string; permissions: AppPermission[] }; async function fileToUint8Array(file: File): Promise { return new Promise((resolve, reject) => { diff --git a/apps/meteor/ee/client/apps/orchestrator.ts b/apps/meteor/ee/client/apps/orchestrator.ts index eaaf367796ae..a921c8cb61be 100644 --- a/apps/meteor/ee/client/apps/orchestrator.ts +++ b/apps/meteor/ee/client/apps/orchestrator.ts @@ -91,12 +91,12 @@ class AppClientOrchestrator { } public async installApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise { - const { app } = await sdk.rest.post<'/apps/'>('/apps/', { + const { app } = (await sdk.rest.post<'/apps'>('/apps', { appId, marketplace: true, version, permissionsGranted, - }); + })) as { app: App }; return app; } diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index d4c9165a9281..ea259fef5f0c 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -321,16 +321,18 @@ export class AppsRestApi { }); } - buff = Buffer.from(await response.arrayBuffer()); + buff = await response.buffer(); } catch (e: any) { orchestrator.getRocketChatLogger().error('Error getting the app from url:', e.response.data); return API.v1.internalError(); } if (this.bodyParams.downloadOnly) { + apiDeprecationLogger.parameter(this.request.route, 'downloadOnly', '7.0.0', this.response); + return API.v1.success({ buff }); } - } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { + } else if ('appId' in this.bodyParams && this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index c16658c67ec2..b157de26916a 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -506,6 +506,8 @@ "App_Information": "App Information", "Apps_context_enterprise": "Enterprise", "App_Installation": "App Installation", + "App_Installation_Deprecation_Title": "Deprecation Warning", + "App_Installation_Deprecation": "Install apps from URL is deprecated and will be removed in the next major release.", "App_not_enabled": "App not enabled", "App_not_found": "App not found", "App_status_auto_enabled": "Enabled", diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 5d4dfa8f4588..b4362d87ba41 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -209,7 +209,7 @@ export type AppsEndpoints = { }; }; - '/apps/': { + '/apps': { GET: | ((params: { buildExternalUrl: 'true'; purchaseType?: 'buy' | 'subscription'; appId?: string; details?: 'true' | 'false' }) => { url: string; @@ -239,15 +239,27 @@ export type AppsEndpoints = { }[]) | (() => { apps: App[] }); - POST: (params: { - appId: string; - marketplace: boolean; - version: string; - permissionsGranted?: IPermission[]; - url?: string; - downloadOnly?: boolean; - }) => { - app: App; + POST: { + ( + params: + | { + appId: string; + marketplace: boolean; + version: string; + permissionsGranted?: IPermission[]; + url?: string; + downloadOnly?: boolean; + } + | { url: string; downloadOnly?: boolean }, + ): + | { + app: App; + } + | { + buff: { + data: ArrayLike; + }; + }; }; }; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 3b8197ce20bf..044282784cdf 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -186,7 +186,7 @@ export type MatchPathPattern = TPath extends any ? Extract = Extract< PathPattern, - `${TBasePath}/${TSubPathPattern}` | TSubPathPattern + `${TBasePath}${TSubPathPattern extends '' ? TSubPathPattern : `/${TSubPathPattern}`}` | TSubPathPattern >; type GetParams = TOperation extends (...args: any) => any ? Parameters[0] : never; diff --git a/yarn.lock b/yarn.lock index 5953649266f6..93ace4f75e01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9147,18 +9147,6 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/fuselage-hooks@npm:^0.33.0": - version: 0.33.0 - resolution: "@rocket.chat/fuselage-hooks@npm:0.33.0" - dependencies: - use-sync-external-store: ~1.2.0 - peerDependencies: - "@rocket.chat/fuselage-tokens": "*" - react: ^17.0.2 - checksum: addf78db893c3185d4d5fc347e1aa81710e0939505a08db77e524740d05555c5e8556d2b6f0e6425270621bdd7230d13278add77ce38197c048af30207cce853 - languageName: node - linkType: hard - "@rocket.chat/fuselage-polyfills@npm:~0.31.25": version: 0.31.25 resolution: "@rocket.chat/fuselage-polyfills@npm:0.31.25"