diff --git a/apps/meteor/app/apps/server/communication/rest.js b/apps/meteor/app/apps/server/communication/rest.js index 4cfd13be58b0..632a57ea24f7 100644 --- a/apps/meteor/app/apps/server/communication/rest.js +++ b/apps/meteor/app/apps/server/communication/rest.js @@ -528,6 +528,38 @@ export class AppsRestApi { }, ); + this.api.addRoute( + ':id/versions', + { authRequired: true, permissionsRequired: ['manage-apps'] }, + { + get() { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = {}; // DO NOT ATTACH THE FRAMEWORK/ENGINE VERSION HERE. + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let result; + try { + result = HTTP.get(`${baseUrl}/v1/apps/${this.urlParams.id}`, { + headers, + }); + } catch (e) { + return handleError('Unable to access Marketplace. Does the server has access to the internet?', e); + } + + if (!result || result.statusCode !== 200) { + orchestrator.getRocketChatLogger().error('Error getting the App versions from the Marketplace:', result.data); + return API.v1.failure(); + } + + return API.v1.success({ apps: result.data }); + }, + }, + ); + this.api.addRoute( ':id/sync', { authRequired: true, permissionsRequired: ['manage-apps'] }, diff --git a/apps/meteor/client/views/admin/apps/APIsDisplay.tsx b/apps/meteor/client/views/admin/apps/APIsDisplay.tsx index 11869cd5fa7f..2cfbde0103c3 100644 --- a/apps/meteor/client/views/admin/apps/APIsDisplay.tsx +++ b/apps/meteor/client/views/admin/apps/APIsDisplay.tsx @@ -1,5 +1,5 @@ import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; -import { Box, Divider } from '@rocket.chat/fuselage'; +import { Box } from '@rocket.chat/fuselage'; import { useAbsoluteUrl, useTranslation } from '@rocket.chat/ui-contexts'; import React, { FC, Fragment } from 'react'; @@ -16,7 +16,6 @@ const APIsDisplay: FC = ({ apis }) => { return ( <> - {t('APIs')} diff --git a/apps/meteor/client/views/admin/apps/LogsLoading.tsx b/apps/meteor/client/views/admin/apps/AccordionLoading.tsx similarity index 83% rename from apps/meteor/client/views/admin/apps/LogsLoading.tsx rename to apps/meteor/client/views/admin/apps/AccordionLoading.tsx index 7727df8be688..bb6405c584cd 100644 --- a/apps/meteor/client/views/admin/apps/LogsLoading.tsx +++ b/apps/meteor/client/views/admin/apps/AccordionLoading.tsx @@ -1,7 +1,7 @@ import { Box, Skeleton, Margins } from '@rocket.chat/fuselage'; import React, { FC } from 'react'; -const LogsLoading: FC = () => ( +const AccordionLoading: FC = () => ( @@ -11,4 +11,4 @@ const LogsLoading: FC = () => ( ); -export default LogsLoading; +export default AccordionLoading; diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPageContent.tsx b/apps/meteor/client/views/admin/apps/AppDetails.tsx similarity index 91% rename from apps/meteor/client/views/admin/apps/AppDetailsPageContent.tsx rename to apps/meteor/client/views/admin/apps/AppDetails.tsx index 22067c35d16d..12d7521bc53d 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsPageContent.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetails.tsx @@ -3,20 +3,22 @@ import { ExternalLink } from '@rocket.chat/ui-client'; import { TranslationKey, useTranslation } from '@rocket.chat/ui-contexts'; import React, { FC } from 'react'; +import APIsDisplay from './APIsDisplay'; import ScreenshotCarouselAnchor from './components/ScreenshotCarouselAnchor'; import { AppInfo } from './definitions/AppInfo'; -type AppDetailsPageContentProps = { +type AppDetailsProps = { app: AppInfo; }; -const AppDetailsPageContent: FC = ({ app }) => { +const AppDetails: FC = ({ app }) => { const { author: { homepage, support }, detailedDescription, description, categories = [], screenshots, + apis, } = app; const t = useTranslation(); @@ -90,10 +92,12 @@ const AppDetailsPageContent: FC = ({ app }) => { + + {apis?.length && } ); }; -export default AppDetailsPageContent; +export default AppDetails; diff --git a/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx b/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx index 5e7d522c5cfa..93ff4c3f17be 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx @@ -1,6 +1,6 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import { formatDistanceStrict } from 'date-fns'; +import moment from 'moment'; import React, { ReactElement } from 'react'; import AppAvatar from '../../../components/avatar/AppAvatar'; @@ -12,7 +12,7 @@ import { App } from './types'; const AppDetailsHeader = ({ app }: { app: App }): ReactElement => { const t = useTranslation(); const { iconFileData, name, author, version, iconFileContent, installed, isSubscribed, modifiedAt, bundledIn, description } = app; - const lastUpdated = modifiedAt && formatDistanceStrict(new Date(modifiedAt), new Date(), { addSuffix: false }); + const lastUpdated = modifiedAt && moment(modifiedAt).fromNow(); return ( diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx b/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx index eaa7831b4f19..e38059b57e5d 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsPage.tsx @@ -7,11 +7,11 @@ import React, { useState, useCallback, useRef, FC } from 'react'; import { ISettings } from '../../../../app/apps/client/@types/IOrchestrator'; import { Apps } from '../../../../app/apps/client/orchestrator'; import Page from '../../../components/Page'; -import APIsDisplay from './APIsDisplay'; +import AppDetails from './AppDetails'; import AppDetailsHeader from './AppDetailsHeader'; -import AppDetailsPageContent from './AppDetailsPageContent'; -import AppLogsPage from './AppLogsPage'; -import AppSecurityPage from './AppSecurityPage'; +import AppLogs from './AppLogs'; +import AppReleases from './AppReleases'; +import AppSecurity from './AppSecurity'; import LoadingDetails from './LoadingDetails'; import SettingsDisplay from './SettingsDisplay'; import { handleAPIError } from './helpers'; @@ -26,8 +26,9 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { const settingsRef = useRef>({}); const appData = useAppInfo(id); - const [, urlParams] = useCurrentRoute(); + const [routeName, urlParams] = useCurrentRoute(); const appsRoute = useRoute('admin-apps'); + const marketplaceRoute = useRoute('admin-marketplace'); const tab = useRouteParameter('tab'); const [currentRouteName] = useCurrentRoute(); @@ -38,8 +39,7 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { const router = useRoute(currentRouteName); const handleReturn = useMutableCallback((): void => router.push({})); - const { installed, settings, apis, privacyPolicySummary, permissions, tosLink, privacyLink } = appData || {}; - const showApis = apis?.length; + const { installed, settings, privacyPolicySummary, permissions, tosLink, privacyLink } = appData || {}; const saveAppSettings = useCallback(async () => { const { current } = settingsRef; @@ -58,8 +58,14 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { setIsSaving(false); }, [id, settings]); - const handleTabClick = (tab: 'details' | 'security' | 'logs' | 'settings'): void => { - appsRoute.replace({ ...urlParams, tab }); + const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs'): void => { + if (routeName === 'admin-marketplace') { + marketplaceRoute.replace({ ...urlParams, tab }); + } + + if (routeName === 'admin-apps') { + appsRoute.replace({ ...urlParams, tab }); + } }; return ( @@ -89,8 +95,8 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { )} {Boolean(installed) && ( - handleTabClick('logs')} selected={tab === 'logs'}> - {t('Logs')} + handleTabClick('releases')} selected={tab === 'releases'}> + {t('Releases')} )} {Boolean(installed && settings && Object.values(settings).length) && ( @@ -98,19 +104,26 @@ const AppDetailsPage: FC<{ id: string }> = function AppDetailsPage({ id }) { {t('Settings')} )} + {Boolean(installed) && ( + handleTabClick('logs')} selected={tab === 'logs'}> + {t('Logs')} + + )} - {Boolean(!tab || tab === 'details') && } - {Boolean((!tab || tab === 'details') && !!showApis) && } + {Boolean(!tab || tab === 'details') && } + {tab === 'security' && ( - )} - {tab === 'logs' && } + + {tab === 'releases' && } + {Boolean(tab === 'settings' && settings && Object.values(settings).length) && ( = function AppDetailsPage({ id }) { settingsRef={settingsRef} /> )} + + {tab === 'logs' && } )} diff --git a/apps/meteor/client/views/admin/apps/AppLogsPage.js b/apps/meteor/client/views/admin/apps/AppLogs.js similarity index 59% rename from apps/meteor/client/views/admin/apps/AppLogsPage.js rename to apps/meteor/client/views/admin/apps/AppLogs.js index b538121a6e24..d6078929eb14 100644 --- a/apps/meteor/client/views/admin/apps/AppLogsPage.js +++ b/apps/meteor/client/views/admin/apps/AppLogs.js @@ -3,10 +3,9 @@ import { useSafely } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState, useEffect } from 'react'; -import Page from '../../../components/Page'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; +import AccordionLoading from './AccordionLoading'; import LogItem from './LogItem'; -import LogsLoading from './LogsLoading'; const useAppWithLogs = ({ id }) => { const [data, setData] = useSafely(useState({})); @@ -32,7 +31,7 @@ const useAppWithLogs = ({ id }) => { return [filteredData, total, fetchData]; }; -function AppLogsPage({ id, ...props }) { +function AppLogs({ id }) { const formatDateAndTime = useFormatDateAndTime(); const [app] = useAppWithLogs({ id }); @@ -41,32 +40,28 @@ function AppLogsPage({ id, ...props }) { const showData = !loading && !app.error; return ( - - - {loading && } - {app.error && ( - - {app.error.message} - - )} - {showData && ( - <> - - {app.logs && - app.logs.map((log) => ( - - ))} - - - )} - - + <> + {loading && } + {app.error && ( + + {app.error.message} + + )} + {showData && ( + + {app.logs && + app.logs.map((log) => ( + + ))} + + )} + ); } -export default AppLogsPage; +export default AppLogs; diff --git a/apps/meteor/client/views/admin/apps/AppReleases.tsx b/apps/meteor/client/views/admin/apps/AppReleases.tsx new file mode 100644 index 000000000000..ac44fa93e3c1 --- /dev/null +++ b/apps/meteor/client/views/admin/apps/AppReleases.tsx @@ -0,0 +1,52 @@ +import { Accordion } from '@rocket.chat/fuselage'; +import React, { useEffect, useState } from 'react'; + +import { useEndpointData } from '../../../hooks/useEndpointData'; +import { AsyncStatePhase } from '../../../lib/asyncState/AsyncStatePhase'; +import AccordionLoading from './AccordionLoading'; +import ReleaseItem from './ReleaseItem'; + +type release = { + version: string; + createdDate: string; + detailedChangelog: { + raw: string; + rendered: string; + }; +}; + +const AppReleases = ({ id }: { id: string }): JSX.Element => { + const { value, phase, error } = useEndpointData(`/apps/${id}/versions`); + + const [releases, setReleases] = useState([] as release[]); + + const isLoading = phase === AsyncStatePhase.LOADING; + const isSuccess = phase === AsyncStatePhase.RESOLVED; + const didFail = phase === AsyncStatePhase.REJECTED || error; + + useEffect(() => { + if (isSuccess && value?.apps) { + const { apps } = value; + + setReleases( + apps.map((app) => ({ + version: app.version, + createdDate: app.createdDate, + detailedChangelog: app.detailedChangelog, + })), + ); + } + }, [isSuccess, value]); + + return ( + <> + + {didFail && error} + {isLoading && } + {isSuccess && releases.length && releases.map((release) => )} + + + ); +}; + +export default AppReleases; diff --git a/apps/meteor/client/views/admin/apps/AppSecurityPage.tsx b/apps/meteor/client/views/admin/apps/AppSecurity.tsx similarity index 92% rename from apps/meteor/client/views/admin/apps/AppSecurityPage.tsx rename to apps/meteor/client/views/admin/apps/AppSecurity.tsx index cfcd23db94d8..d44729e57df2 100644 --- a/apps/meteor/client/views/admin/apps/AppSecurityPage.tsx +++ b/apps/meteor/client/views/admin/apps/AppSecurity.tsx @@ -3,14 +3,14 @@ import { Box, Margins } from '@rocket.chat/fuselage'; import { TranslationKey, useTranslation } from '@rocket.chat/ui-contexts'; import React, { FC } from 'react'; -type AppSecurityPageProps = { +type AppSecurityProps = { privacyPolicySummary: string | undefined; appPermissions: AppPermission[] | undefined; tosLink: string | undefined; privacyLink: string | undefined; }; -const AppSecurityPage: FC = ({ privacyPolicySummary, appPermissions, tosLink, privacyLink }) => { +const AppSecurity: FC = ({ privacyPolicySummary, appPermissions, tosLink, privacyLink }) => { const t = useTranslation(); const defaultPermissions = [ @@ -89,4 +89,4 @@ const AppSecurityPage: FC = ({ privacyPolicySummary, appPe ); }; -export default AppSecurityPage; +export default AppSecurity; diff --git a/apps/meteor/client/views/admin/apps/ReleaseItem.tsx b/apps/meteor/client/views/admin/apps/ReleaseItem.tsx new file mode 100644 index 000000000000..c16bd6b638bb --- /dev/null +++ b/apps/meteor/client/views/admin/apps/ReleaseItem.tsx @@ -0,0 +1,45 @@ +import { Accordion, Box } from '@rocket.chat/fuselage'; +import React from 'react'; + +import { useTimeAgo } from '../../../hooks/useTimeAgo'; + +type release = { + version: string; + createdDate: string; + detailedChangelog: { + raw: string; + rendered: string; + }; +}; + +type ReleaseItemProps = { + release: release; + key: string; +}; + +const ReleaseItem = ({ release, key, ...props }: ReleaseItemProps): JSX.Element => { + const formatDate = useTimeAgo(); + + const title = ( + + + {release.version} + + + {formatDate(release.createdDate)} + + + ); + + return ( + + {release.detailedChangelog?.rendered ? ( + + ) : ( + 'No release information provided' + )} + + ); +}; + +export default ReleaseItem; diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index b1946f331949..b00a8dfcc7b8 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -21,7 +21,7 @@ registerAdminRoute('/apps/what-is-it', { component: lazy(() => import('./apps/AppsWhatIsIt')), }); -registerAdminRoute('/marketplace/:context?/:id?/:version?', { +registerAdminRoute('/marketplace/:context?/:id?/:version?/:tab?', { name: 'admin-marketplace', component: lazy(() => import('./apps/AppsRoute')), }); diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index a4569869f05b..1110f3fe6d6e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2945,7 +2945,7 @@ "Markdown_SupportSchemesForLink": "Markdown Support Schemes for Link", "Markdown_SupportSchemesForLink_Description": "Comma-separated list of allowed schemes", "Marketplace": "Marketplace", - "Marketplace_app_last_updated": "Last updated __lastUpdated__ ago", + "Marketplace_app_last_updated": "Last updated __lastUpdated__", "Marketplace_view_marketplace": "View Marketplace", "Marketplace_error": "Cannot connect to internet or your workspace may be an offline install.", "MAU_value": "MAU __value__", @@ -3658,6 +3658,7 @@ "Registration_via_Admin": "Registration via Admin", "Regular_Expressions": "Regular Expressions", "Release": "Release", + "Releases": "Releases", "Religious": "Religious", "Reload": "Reload", "Reload_page": "Reload Page", diff --git a/packages/core-typings/src/Apps.ts b/packages/core-typings/src/Apps.ts index 510b52cda6ec..9d91f9baeb9a 100644 --- a/packages/core-typings/src/Apps.ts +++ b/packages/core-typings/src/Apps.ts @@ -108,4 +108,5 @@ export type App = { modifiedAt: string; permissions: AppPermission[]; languages: string[]; + createdDate: string; }; diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 1a8c801bf790..c78a3d83f72e 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -21,6 +21,13 @@ export type AppsEndpoints = { }; }; + '/apps/:id/versions': { + GET: () => { + apps: App[]; + success: boolean; + }; + }; + '/apps/actionButtons': { GET: () => IUIActionButton[]; };