diff --git a/.changeset/brown-donuts-drive.md b/.changeset/brown-donuts-drive.md new file mode 100644 index 0000000000000..8e072b97aabae --- /dev/null +++ b/.changeset/brown-donuts-drive.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Added a new admin page called `Subscription`, this page is responsible of managing the current workspace subscription and it has a overview of the usage and limits of the plan diff --git a/apps/meteor/app/api/server/v1/cloud.ts b/apps/meteor/app/api/server/v1/cloud.ts index d904d40d84ff1..6fb675cfccd42 100644 --- a/apps/meteor/app/api/server/v1/cloud.ts +++ b/apps/meteor/app/api/server/v1/cloud.ts @@ -2,11 +2,13 @@ import { check } from 'meteor/check'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; +import { getCheckoutUrl } from '../../../cloud/server/functions/getCheckoutUrl'; import { getConfirmationPoll } from '../../../cloud/server/functions/getConfirmationPoll'; import { registerPreIntentWorkspaceWizard } from '../../../cloud/server/functions/registerPreIntentWorkspaceWizard'; import { retrieveRegistrationStatus } from '../../../cloud/server/functions/retrieveRegistrationStatus'; import { saveRegistrationData } from '../../../cloud/server/functions/saveRegistrationData'; import { startRegisterWorkspaceSetupWizard } from '../../../cloud/server/functions/startRegisterWorkspaceSetupWizard'; +import { syncWorkspace } from '../../../cloud/server/functions/syncWorkspace'; import { API } from '../api'; API.v1.addRoute( @@ -126,3 +128,51 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'cloud.syncWorkspace', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 }, + }, + { + async post() { + try { + await syncWorkspace(); + + return API.v1.success({ success: true }); + } catch (error) { + return API.v1.failure('Error during workspace sync'); + } + }, + }, +); + +/** + * Declaring endpoint here because we don't want this available to the sdk client + */ +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Endpoints { + '/v1/cloud.checkoutUrl': { + GET: () => { url: string }; + }; + } +} + +API.v1.addRoute( + 'cloud.checkoutUrl', + { authRequired: true, permissionsRequired: ['manage-cloud'] }, + { + async get() { + const checkoutUrl = await getCheckoutUrl(); + + if (!checkoutUrl.url) { + return API.v1.failure(); + } + + return API.v1.success({ url: checkoutUrl.url }); + }, + }, +); diff --git a/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts b/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts new file mode 100644 index 0000000000000..046728cb16807 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts @@ -0,0 +1,45 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { SystemLogger } from '../../../../server/lib/logger/system'; +import { settings } from '../../../settings/server'; +import { getURL } from '../../../utils/server/getURL'; +import { getWorkspaceAccessTokenOrThrow } from './getWorkspaceAccessToken'; + +export const getCheckoutUrl = async () => { + try { + const token = await getWorkspaceAccessTokenOrThrow(false, 'workspace:billing'); + + const subscriptionURL = getURL('admin/subscription', { + full: true, + }); + + const body = { + okCallback: `${subscriptionURL}?subscriptionSuccess=true`, + cancelCallback: subscriptionURL, + }; + + const billingUrl = settings.get('Cloud_Billing_Url'); + + const response = await fetch(`${billingUrl}/api/v2/checkout`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body, + }); + + if (!response.ok) { + throw new Error(await response.json()); + } + + return response.json(); + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to get Checkout URL with Rocket.Chat Billing Service', + url: '/api/v2/checkout', + err, + }); + + throw err; + } +}; diff --git a/apps/meteor/app/utils/lib/getURL.ts b/apps/meteor/app/utils/lib/getURL.ts index fae79d65b1dba..3d757abb6c83c 100644 --- a/apps/meteor/app/utils/lib/getURL.ts +++ b/apps/meteor/app/utils/lib/getURL.ts @@ -77,7 +77,19 @@ export const _getURL = ( export const getURLWithoutSettings = ( path: string, // eslint-disable-next-line @typescript-eslint/naming-convention - { cdn = true, full = false, cloud = false, cloud_route = '', cloud_params = {} }: Record = {}, + { + cdn = true, + full = false, + cloud = false, + cloud_route = '', + cloud_params = {}, + }: { + cdn?: boolean; + full?: boolean; + cloud?: boolean; + cloud_route?: string; + cloud_params?: Record; + }, cdnPrefix: string, siteUrl: string, cloudDeepLinkUrl?: string, diff --git a/apps/meteor/app/utils/server/getURL.ts b/apps/meteor/app/utils/server/getURL.ts index 17e0a68efa8e7..4703569736add 100644 --- a/apps/meteor/app/utils/server/getURL.ts +++ b/apps/meteor/app/utils/server/getURL.ts @@ -3,7 +3,13 @@ import { getURLWithoutSettings } from '../lib/getURL'; export const getURL = function ( path: string, // eslint-disable-next-line @typescript-eslint/naming-convention - params: Record = {}, + params: { + cdn?: boolean; + full?: boolean; + cloud?: boolean; + cloud_route?: string; + cloud_params?: Record; + } = {}, cloudDeepLinkUrl?: string, ): string { const cdnPrefix = settings.get('CDN_PREFIX') || ''; diff --git a/apps/meteor/client/hooks/useIsEnterprise.ts b/apps/meteor/client/hooks/useIsEnterprise.ts index 4be3494ad969f..f91d754014c42 100644 --- a/apps/meteor/client/hooks/useIsEnterprise.ts +++ b/apps/meteor/client/hooks/useIsEnterprise.ts @@ -1,23 +1,13 @@ import type { OperationResult } from '@rocket.chat/rest-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -const queryKey = ['licenses', 'isEnterprise'] as const; - -export const useIsEnterprise = ( - options?: UseQueryOptions< - OperationResult<'GET', '/v1/licenses.isEnterprise'>, - unknown, - OperationResult<'GET', '/v1/licenses.isEnterprise'>, - typeof queryKey - >, -): UseQueryResult> => { +export const useIsEnterprise = (): UseQueryResult> => { const isEnterpriseEdition = useEndpoint('GET', '/v1/licenses.isEnterprise'); - return useQuery(queryKey, () => isEnterpriseEdition(), { + return useQuery(['licenses', 'isEnterprise'], () => isEnterpriseEdition(), { keepPreviousData: true, staleTime: Infinity, - ...options, }); }; diff --git a/apps/meteor/client/hooks/useIsSelfHosted.ts b/apps/meteor/client/hooks/useIsSelfHosted.ts new file mode 100644 index 0000000000000..eb3620e20ea3e --- /dev/null +++ b/apps/meteor/client/hooks/useIsSelfHosted.ts @@ -0,0 +1,9 @@ +import { useStatistics } from '../views/hooks/useStatistics'; + +export const useIsSelfHosted = (): { isSelfHosted: boolean; isLoading: boolean } => { + const { data, isLoading } = useStatistics(); + + const isSelfHosted = data?.deploy?.platform !== 'rocket-cloud'; + + return { isSelfHosted, isLoading }; +}; diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index ae965d1059586..5c9b000d65d95 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -7,6 +7,10 @@ import { useEffect } from 'react'; type LicenseDataType = Awaited>['license']; +type LicenseParams = { + loadValues?: boolean; +}; + const invalidateQueryClientLicenses = (() => { let timeout: ReturnType | undefined; @@ -19,7 +23,7 @@ const invalidateQueryClientLicenses = (() => { }; })(); -export const useLicense = (): UseQueryResult> => { +export const useLicense = (params?: LicenseParams): UseQueryResult> => { const getLicenses = useEndpoint('GET', '/v1/licenses.info'); const queryClient = useQueryClient(); @@ -28,7 +32,7 @@ export const useLicense = (): UseQueryResult> => { useEffect(() => notify('license', () => invalidateQueryClientLicenses(queryClient)), [notify, queryClient]); - return useQuery(['licenses', 'getLicenses'], () => getLicenses({}), { + return useQuery(['licenses', 'getLicenses', params?.loadValues], () => getLicenses({ ...params }), { staleTime: Infinity, keepPreviousData: true, select: (data) => data.license, diff --git a/apps/meteor/client/hooks/useRegistrationStatus.ts b/apps/meteor/client/hooks/useRegistrationStatus.ts index 9260d672bec58..e400bf88cf208 100644 --- a/apps/meteor/client/hooks/useRegistrationStatus.ts +++ b/apps/meteor/client/hooks/useRegistrationStatus.ts @@ -3,11 +3,15 @@ import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -export const useRegistrationStatus = (): UseQueryResult> => { +type useRegistrationStatusReturnType = { + isRegistered?: boolean; +} & UseQueryResult>; + +export const useRegistrationStatus = (): useRegistrationStatusReturnType => { const getRegistrationStatus = useEndpoint('GET', '/v1/cloud.registrationStatus'); const canViewregistrationStatus = usePermission('manage-cloud'); - return useQuery( + const queryResult = useQuery( ['getRegistrationStatus'], () => { if (!canViewregistrationStatus) { @@ -20,4 +24,6 @@ export const useRegistrationStatus = (): UseQueryResult { const isAdmin = useRole('admin'); const setModal = useSetModal(); - const { data: registrationStatusData } = useRegistrationStatus(); - const workspaceRegistered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; + const { isRegistered } = useRegistrationStatus(); const handleRegisterWorkspaceClick = (): void => { const handleModalClose = (): void => setModal(null); @@ -102,10 +101,10 @@ export const useAdministrationItems = (): GenericMenuItemProps[] => { }; const adminItem: GenericMenuItemProps = { id: 'registration', - content: workspaceRegistered ? t('Registration') : t('Register'), + content: isRegistered ? t('Registration') : t('Register'), icon: 'cloud-plus', onClick: () => { - if (workspaceRegistered) { + if (isRegistered) { cloudRoute.push({ context: '/' }); return; } diff --git a/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx b/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx index a75e66ec1e4ba..9a12d45deb7ca 100644 --- a/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx +++ b/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx @@ -14,8 +14,7 @@ const RegisterWorkspace = () => { const t = useTranslation(); const setModal = useSetModal(); - const { data: registrationStatusData, isLoading, isError, refetch } = useRegistrationStatus(); - const isWorkspaceRegistered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; + const { isRegistered, isLoading, isError, refetch } = useRegistrationStatus(); if (isLoading || isError) { return null; @@ -26,7 +25,7 @@ const RegisterWorkspace = () => { setModal(null); refetch(); }; - if (isWorkspaceRegistered) { + if (isRegistered) { setModal(); } else setModal(); }; @@ -43,7 +42,7 @@ const RegisterWorkspace = () => { { - {!isWorkspaceRegistered && {t('RegisterWorkspace_NotRegistered_Title')}} - {isWorkspaceRegistered && {t('Workspace_registered')}} + {!isRegistered && {t('RegisterWorkspace_NotRegistered_Title')}} + {isRegistered && {t('Workspace_registered')}} - {!isWorkspaceRegistered && t('RegisterWorkspace_NotRegistered_Subtitle')} - {isWorkspaceRegistered && t('RegisterWorkspace_Registered_Description')} + {!isRegistered && t('RegisterWorkspace_NotRegistered_Subtitle')} + {isRegistered && t('RegisterWorkspace_Registered_Description')} diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx index 6364c29d416c8..776e04fe1f69e 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx @@ -2,7 +2,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { GenericResourceUsage, GenericResourceUsageSkeleton } from '../../../components/GenericResourceUsage'; -import { useActiveConnections } from './hooks/useActiveConnections'; +import { useActiveConnections } from '../../hooks/useActiveConnections'; const CustomUserActiveConnections = () => { const t = useTranslation(); diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusService.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusService.tsx index fcc4ead8382cf..83f34459efb0c 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusService.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusService.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { ContextualbarContent, ContextualbarFooter } from '../../../components/Contextualbar'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; -import { useActiveConnections } from './hooks/useActiveConnections'; +import { useActiveConnections } from '../../hooks/useActiveConnections'; const CustomUserStatusService = () => { const t = useTranslation(); diff --git a/apps/meteor/client/views/admin/info/InformationPage.tsx b/apps/meteor/client/views/admin/info/InformationPage.tsx index 3bab6ef2dac40..775e7658a036d 100644 --- a/apps/meteor/client/views/admin/info/InformationPage.tsx +++ b/apps/meteor/client/views/admin/info/InformationPage.tsx @@ -4,10 +4,10 @@ import type { IInstance } from '@rocket.chat/rest-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; -import SeatsCard from '../../../../ee/client/views/admin/info/SeatsCard'; import { useSeatsCap } from '../../../../ee/client/views/admin/users/useSeatsCap'; import Page from '../../../components/Page'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; +import SeatsCard from '../subscription/components/cards/SeatsCard'; import DeploymentCard from './DeploymentCard'; import LicenseCard from './LicenseCard'; import UsageCard from './UsageCard'; @@ -95,9 +95,9 @@ const InformationPage = memo(function InformationPage({ - {!showSeatCap && ( + {seatsCap && seatsCap.maxActiveUsers !== Infinity && ( - + )} diff --git a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx index d76731f0d2c4d..d23b9f9c3f131 100644 --- a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from 'react'; import React from 'react'; import { useAutoSequence } from '../../../stories/hooks/useAutoSequence'; -import UsagePieGraph from './UsagePieGraph'; +import UsagePieGraph from '../subscription/components/UsagePieGraph'; export default { title: 'Admin/Info/UsagePieGraph', diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index d9b1dd4473977..20b86a210f950 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -105,6 +105,10 @@ declare module '@rocket.chat/ui-contexts' { pathname: `/admin/moderation${`/${string}` | ''}${`/${string}` | ''}`; pattern: '/admin/moderation/:context?/:id?'; }; + 'subscription': { + pathname: `/admin/subscription`; + pattern: '/admin/subscription'; + }; } } @@ -237,3 +241,8 @@ registerAdminRoute('/device-management/:context?/:id?', { name: 'device-management', component: lazy(() => import('../../../ee/client/views/admin/deviceManagement/DeviceManagementAdminRoute')), }); + +registerAdminRoute('/subscription', { + name: 'subscription', + component: lazy(() => import('./subscription/SubscriptionRoute')), +}); diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index b9650e3c93d08..c893b819962ae 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -13,6 +13,12 @@ export const { icon: 'info-circled', permissionGranted: (): boolean => hasPermission('view-statistics'), }, + { + href: '/admin/subscription', + i18nLabel: 'Subscription', + icon: 'card', + permissionGranted: (): boolean => hasPermission('manage-cloud'), + }, { href: '/admin/registration', i18nLabel: 'Registration', diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx new file mode 100644 index 0000000000000..71a9faeee5d20 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx @@ -0,0 +1,148 @@ +import { Box, Button, ButtonGroup, Callout, Grid, Throbber } from '@rocket.chat/fuselage'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { t } from 'i18next'; +import React, { memo, useEffect } from 'react'; + +import Page from '../../../components/Page'; +import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; +import { useLicense } from '../../../hooks/useLicense'; +import { useRegistrationStatus } from '../../../hooks/useRegistrationStatus'; +import SubscriptionPageSkeleton from './SubscriptionPageSkeleton'; +import UpgradeButton from './components/UpgradeButton'; +import UpgradeToGetMore from './components/UpgradeToGetMore'; +import ActiveSessionsCard from './components/cards/ActiveSessionsCard'; +import ActiveSessionsPeakCard from './components/cards/ActiveSessionsPeakCard'; +import AppsUsageCard from './components/cards/AppsUsageCard'; +import CountMACCard from './components/cards/CountMACCard'; +import CountSeatsCard from './components/cards/CountSeatsCard'; +import FeaturesCard from './components/cards/FeaturesCard'; +import MACCard from './components/cards/MACCard'; +import PlanCard from './components/cards/PlanCard'; +import PlanCardCommunity from './components/cards/PlanCard/PlanCardCommunity'; +import SeatsCard from './components/cards/SeatsCard'; +import { useWorkspaceSync } from './hooks/useWorkspaceSync'; + +const SubscriptionPage = () => { + const router = useRouter(); + const { data: enterpriseData } = useIsEnterprise(); + const { isRegistered } = useRegistrationStatus(); + const { data: licensesData, isLoading: isLicenseLoading, refetch: refetchLicense } = useLicense({ loadValues: true }); + const syncLicenseUpdate = useWorkspaceSync(); + + const { subscriptionSuccess } = router.getSearchParameters(); + + const { license, limits, activeModules = [] } = licensesData || {}; + const { isEnterprise = true } = enterpriseData || {}; + + const getKeyLimit = (key: 'monthlyActiveContacts' | 'activeUsers') => { + const { max, value } = limits?.[key] || {}; + return { + max: max && max !== -1 ? max : Infinity, + value, + }; + }; + + const macLimit = getKeyLimit('monthlyActiveContacts'); + const seatsLimit = getKeyLimit('activeUsers'); + + const handleSyncLicenseUpdateClick = () => { + syncLicenseUpdate.mutate(); + }; + + useEffect(() => { + if (subscriptionSuccess && syncLicenseUpdate.isIdle) { + syncLicenseUpdate.mutate(undefined, { + onSuccess: () => + router.navigate( + { + name: router.getRouteName()!, + params: Object.fromEntries(Object.entries(router.getSearchParameters()).filter(([key]) => key !== 'subscriptionSuccess')), + }, + { replace: true }, + ), + }); + } + }, [refetchLicense, router, subscriptionSuccess, syncLicenseUpdate]); + + return ( + + + + {isRegistered && ( + + )} + + + + + + {subscriptionSuccess && ( + + {t('Sync_license_update_Callout')} + + )} + {isLicenseLoading && } + {!isLicenseLoading && ( + + + + {license && ( + + )} + {!license && } + + + + + + {seatsLimit.value !== undefined && ( + + {seatsLimit.max !== Infinity ? ( + + ) : ( + + )} + + )} + + {macLimit.value !== undefined && ( + + {macLimit.max !== Infinity ? ( + + ) : ( + + )} + + )} + + {limits?.marketplaceApps !== undefined && ( + + + + )} + + + + + + + + + + + )} + + + ); +}; + +export default memo(SubscriptionPage); diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionPageSkeleton.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionPageSkeleton.tsx new file mode 100644 index 0000000000000..a787f26ecbfa4 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/SubscriptionPageSkeleton.tsx @@ -0,0 +1,23 @@ +import { Box, Grid, Skeleton } from '@rocket.chat/fuselage'; +import React, { memo } from 'react'; + +const SubscriptionPageSkeleton = () => ( + + + + + + + + + + + + + + + + +); + +export default memo(SubscriptionPageSkeleton); diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionRoute.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionRoute.tsx new file mode 100644 index 0000000000000..99fb216c01ce4 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/SubscriptionRoute.tsx @@ -0,0 +1,18 @@ +import { usePermission } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; + +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import SubscriptionPage from './SubscriptionPage'; + +const SubscriptionRoute = (): ReactElement => { + const canViewSubscription = usePermission('manage-cloud'); + + if (!canViewSubscription) { + return ; + } + + return ; +}; + +export default memo(SubscriptionRoute); diff --git a/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx new file mode 100644 index 0000000000000..2bed47d5b7c4f --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx @@ -0,0 +1,41 @@ +import { Card, CardBody, CardColSection, CardFooter, CardTitle } from '@rocket.chat/ui-client'; +import type { ReactElement, ReactNode } from 'react'; +import React, { memo } from 'react'; + +import InfoTextIconModal from './InfoTextIconModal'; +import UpgradeButton from './UpgradeButton'; + +type FeatureUsageCardProps = { + children?: ReactNode; + card: CardProps; +}; + +export type CardProps = { + title: string; + infoText?: string; + showUpgradeButton?: boolean; + upgradeButtonText?: string; +}; + +const FeatureUsageCard = ({ children, card }: FeatureUsageCardProps): ReactElement => { + const { title, infoText, showUpgradeButton, upgradeButtonText = 'Upgrade' } = card; + return ( + + + {title} {infoText && } + + + + {children} + + + {showUpgradeButton && ( + + + + )} + + ); +}; + +export default memo(FeatureUsageCard); diff --git a/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx b/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx new file mode 100644 index 0000000000000..9316949dcb66c --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx @@ -0,0 +1,33 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import GenericModal from '../../../../components/GenericModal'; + +export type InfoTextIconModalProps = { + title: string; + infoText: string; +}; + +const InfoTextIconModal = ({ title, infoText }: InfoTextIconModalProps): ReactElement => { + const setModal = useSetModal(); + const { t } = useTranslation(); + + const handleInfoClick = () => { + if (!infoText) { + setModal(null); + return; + } + setModal( + setModal(null)}> + {infoText} + , + ); + }; + + return handleInfoClick()} />; +}; + +export default memo(InfoTextIconModal); diff --git a/apps/meteor/client/views/admin/subscription/components/PieGraphCard.tsx b/apps/meteor/client/views/admin/subscription/components/PieGraphCard.tsx new file mode 100644 index 0000000000000..f18bf9da69a73 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/PieGraphCard.tsx @@ -0,0 +1,29 @@ +import colors from '@rocket.chat/fuselage-tokens/colors'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; + +import type { CardProps } from './FeatureUsageCard'; +import FeatureUsageCard from './FeatureUsageCard'; +import type { UsagePieGraphProps } from './UsagePieGraph'; +import UsagePieGraph from './UsagePieGraph'; + +type PieGraphCardProps = { + pieGraph: UsagePieGraphProps; + card: CardProps; +}; + +const PieGraphCard = ({ pieGraph, card }: PieGraphCardProps): ReactElement => { + const t = useTranslation(); + + const quantityAvailable = pieGraph && Math.max(pieGraph.total - pieGraph.used, 0); + const color = pieGraph && pieGraph.used / pieGraph.total >= 0.8 ? colors.d500 : undefined; + + return ( + + ) + + ); +}; + +export default memo(PieGraphCard); diff --git a/apps/meteor/client/views/admin/subscription/components/UpgradeButton.tsx b/apps/meteor/client/views/admin/subscription/components/UpgradeButton.tsx new file mode 100644 index 0000000000000..8beaace5f93b4 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/UpgradeButton.tsx @@ -0,0 +1,34 @@ +import { Button, ButtonGroup, Throbber } from '@rocket.chat/fuselage'; +import type { ButtonProps } from '@rocket.chat/fuselage/dist/components/Button/Button'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCheckoutUrlAction } from '../hooks/useCheckoutUrl'; + +type UpgradeButtonProps = { + i18nKey?: string; +} & Partial; + +const UpgradeButton = ({ i18nKey = 'Manage_subscription', ...props }: UpgradeButtonProps): ReactElement => { + const { t } = useTranslation(); + const mutation = useCheckoutUrlAction(); + + const handleBtnClick = () => { + if (mutation.isLoading) { + return; + } + + mutation.mutate(); + }; + + return ( + + + + ); +}; + +export default memo(UpgradeButton); diff --git a/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx b/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx new file mode 100644 index 0000000000000..40b1750da7132 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx @@ -0,0 +1,73 @@ +import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Grid, Button } from '@rocket.chat/fuselage'; +import { Card, CardBody, CardTitle, FramedIcon } from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PRICING_LINK } from '../utils/links'; + +type UpgradeToGetMoreProps = { + activeModules: string[]; + isEnterprise: boolean; +}; + +const enterpriseModules = [ + 'scalability', + 'accessibility-certification', + 'engagement-dashboard', + 'oauth-enterprise', + 'custom-roles', + 'auditing', +]; + +const UpgradeToGetMore = ({ activeModules }: UpgradeToGetMoreProps) => { + const { t } = useTranslation(); + + const upgradeModules = enterpriseModules + .filter((module) => !activeModules.includes(module)) + .map((module) => { + return { + title: t(`UpgradeToGetMore_${module}_Title`), + body: t(`UpgradeToGetMore_${module}_Body`), + }; + }); + + if (upgradeModules?.length === 0) { + return null; + } + + return ( + + + + {t('UpgradeToGetMore_Headline')} + {t('UpgradeToGetMore_Subtitle')} + + + {upgradeModules.map(({ title, body }, index) => ( + + + + + + + {title} + + + + + + {body} + + + + + ))} + + + + ); +}; + +export default memo(UpgradeToGetMore); diff --git a/apps/meteor/client/views/admin/info/UsagePieGraph.tsx b/apps/meteor/client/views/admin/subscription/components/UsagePieGraph.tsx similarity index 74% rename from apps/meteor/client/views/admin/info/UsagePieGraph.tsx rename to apps/meteor/client/views/admin/subscription/components/UsagePieGraph.tsx index 935651555e719..af5d4b673410d 100644 --- a/apps/meteor/client/views/admin/info/UsagePieGraph.tsx +++ b/apps/meteor/client/views/admin/subscription/components/UsagePieGraph.tsx @@ -2,23 +2,23 @@ import type { DatumId } from '@nivo/pie'; import { Pie } from '@nivo/pie'; import { Box, Palette } from '@rocket.chat/fuselage'; import type { ReactElement, CSSProperties, ReactNode } from 'react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, memo } from 'react'; -import { useLocalePercentage } from '../../../hooks/useLocalePercentage'; +import { useLocalePercentage } from '../../../../hooks/useLocalePercentage'; type GraphColorsReturn = { [key: string]: string }; const graphColors = (color: CSSProperties['color']): GraphColorsReturn => ({ - used: color || Palette.stroke['stroke-highlight'].toString(), + used: color || Palette.statusColor['status-font-on-success'].toString(), free: Palette.stroke['stroke-extra-light'].toString(), }); -type UsageGraphProps = { +export type UsagePieGraphProps = { used: number; total: number; - label: ReactNode; + label?: ReactNode; color?: string; - size: number; + size?: number; }; type GraphData = Array<{ @@ -27,7 +27,7 @@ type GraphData = Array<{ value: number; }>; -const UsageGraph = ({ used = 0, total = 0, label, color, size }: UsageGraphProps): ReactElement => { +const UsagePieGraph = ({ used = 0, total = 0, label, color, size = 140 }: UsagePieGraphProps): ReactElement => { const parsedData = useMemo( (): GraphData => [ { @@ -77,7 +77,6 @@ const UsageGraph = ({ used = 0, total = 0, label, color, size }: UsageGraphProps alignItems='center' justifyContent='center' position='absolute' - color={color} fontScale='p2m' style={{ left: 0, right: 0, top: 0, bottom: 0 }} > @@ -85,17 +84,14 @@ const UsageGraph = ({ used = 0, total = 0, label, color, size }: UsageGraphProps - - - {used} - {' '} - / {unlimited ? '∞' : total} - - + + {used} / {unlimited ? '∞' : total} + + {label} ); }; -export default UsageGraph; +export default memo(UsagePieGraph); diff --git a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx new file mode 100644 index 0000000000000..97da1c5438b36 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx @@ -0,0 +1,59 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useActiveConnections } from '../../../../hooks/useActiveConnections'; +import type { CardProps } from '../FeatureUsageCard'; +import FeatureUsageCard from '../FeatureUsageCard'; + +const getLimits = ({ max, current }: { max: number; current: number }) => { + const total = max || 0; + const used = current || 0; + const available = total - used; + + const exceedLimit = used >= total; + + return { + total, + used, + available, + exceedLimit, + }; +}; + +const ActiveSessionsCard = (): ReactElement => { + const { t } = useTranslation(); + const result = useActiveConnections(); + + const card: CardProps = { + title: t('ActiveSessions'), + infoText: t('ActiveSessions_InfoText'), + showUpgradeButton: true, + }; + + if (result.isLoading || result.isError) { + return ( + + + + ); + } + + const { total, used, available, exceedLimit } = getLimits(result.data); + + return ( + + + + {used} / {total} + + + {available} {t('ActiveSessions_available')} + + + + ); +}; + +export default ActiveSessionsCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx new file mode 100644 index 0000000000000..b478115bd27e9 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx @@ -0,0 +1,47 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useFormatDate } from '../../../../../hooks/useFormatDate'; +import { useStatistics } from '../../../../hooks/useStatistics'; +import type { CardProps } from '../FeatureUsageCard'; +import FeatureUsageCard from '../FeatureUsageCard'; + +const ActiveSessionsPeakCard = (): ReactElement => { + const { t } = useTranslation(); + const { data, isLoading } = useStatistics(); + const formatDate = useFormatDate(); + + const { maxMonthlyPeakConnections } = data || {}; + + const total = 200; + const used = maxMonthlyPeakConnections || 0; + + const exceedLimit = used >= total; + + const card: CardProps = { + title: t('ActiveSessionsPeak'), + infoText: t('ActiveSessionsPeak_InfoText'), + showUpgradeButton: exceedLimit, + }; + + return ( + + {!isLoading && maxMonthlyPeakConnections ? ( + + + {used} / {total} + + + {formatDate(new Date())} + + + ) : ( + + )} + + ); +}; + +export default ActiveSessionsPeakCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx new file mode 100644 index 0000000000000..0c48957c7a7aa --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx @@ -0,0 +1,65 @@ +import { Box, ProgressBar, Skeleton } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CardProps } from '../FeatureUsageCard'; +import FeatureUsageCard from '../FeatureUsageCard'; + +type AppsUsageCardProps = { + privateAppsLimit?: { value?: number; max: number }; + marketplaceAppsLimit?: { value?: number; max: number }; +}; + +const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCardProps): ReactElement => { + const { t } = useTranslation(); + + const marketplaceAppsEnabled = marketplaceAppsLimit?.value || 0; + const marketplaceAppsLimitCount = marketplaceAppsLimit?.max || 5; + const marketplaceAppsPercentage = Math.round((marketplaceAppsEnabled / marketplaceAppsLimitCount) * 100); + + const privateAppsEnabled = privateAppsLimit?.value || 0; + const privateAppsLimitCount = privateAppsLimit?.max || 3; + const privateAppsPercentage = Math.round((privateAppsEnabled / privateAppsLimitCount) * 100); + + const card: CardProps = { + title: t('Apps'), + infoText: t('Apps_InfoText'), + showUpgradeButton: (marketplaceAppsPercentage || 0) >= 80, + }; + + return ( + + {privateAppsLimit && marketplaceAppsLimit ? ( + + + + {t('Marketplace_apps')} + = 80 ? 'font-danger' : 'status-font-on-success'}> + {marketplaceAppsEnabled} / {marketplaceAppsLimitCount} + + + + = 80 ? 'danger' : 'success'} + /> + + + + {t('Private_apps')} + = 80 ? 'font-danger' : 'status-font-on-success'}> + {privateAppsEnabled} / {privateAppsLimitCount} + + + + = 80 ? 'danger' : 'success'} /> + + + ) : ( + + )} + + ); +}; +export default AppsUsageCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/CountMACCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/CountMACCard.tsx new file mode 100644 index 0000000000000..862504692d1fa --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/CountMACCard.tsx @@ -0,0 +1,25 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import FeatureUsageCard from '../FeatureUsageCard'; + +const CountMACCard = ({ macsCount }: { macsCount: number }): ReactElement => { + const { t } = useTranslation(); + + return ( + + + + {macsCount} + + + ); +}; +export default CountMACCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/CountSeatsCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/CountSeatsCard.tsx new file mode 100644 index 0000000000000..a730c4cdf9fb9 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/CountSeatsCard.tsx @@ -0,0 +1,25 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import FeatureUsageCard from '../FeatureUsageCard'; + +const CountSeatsCard = ({ activeUsers }: { activeUsers: number }): ReactElement => { + const { t } = useTranslation(); + + return ( + + + + {activeUsers} + + + ); +}; +export default CountSeatsCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx new file mode 100644 index 0000000000000..8e0d99a571673 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx @@ -0,0 +1,103 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; +import { CardCol, CardColSection, CardFooter, FramedIcon } from '@rocket.chat/ui-client'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PRICING_LINK } from '../../utils/links'; +import FeatureUsageCard from '../FeatureUsageCard'; +import InfoTextIconModal from '../InfoTextIconModal'; + +type FeatureSet = { + type: 'neutral' | 'success'; + title: string; + infoText?: string; +}; + +type FeaturesCardProps = { + activeModules: string[]; + isEnterprise: boolean; +}; + +const FeaturesCard = ({ activeModules, isEnterprise }: FeaturesCardProps): ReactElement => { + const { t } = useTranslation(); + const mediaQuery = useMediaQuery('(min-width: 1024px)'); + + const getFeatureSet = (modules: string[], isEnterprise: boolean): FeatureSet[] => { + const featureSet: FeatureSet[] = [ + { + type: isEnterprise ? 'success' : 'neutral', + title: 'Premium_and_unlimited_apps', + }, + { + type: isEnterprise ? 'success' : 'neutral', + title: 'Premium_omnichannel_capabilities', + }, + { + type: isEnterprise ? 'success' : 'neutral', + title: 'Unlimited_push_notifications', + }, + { + type: modules.includes('videoconference-enterprise') ? 'success' : 'neutral', + title: 'Video_call_manager', + }, + { + type: modules.includes('hide-watermark') ? 'success' : 'neutral', + title: 'Remove_RocketChat_Watermark', + infoText: 'Remove_RocketChat_Watermark_InfoText', + }, + { + type: modules.includes('scalability') ? 'success' : 'neutral', + title: 'High_scalabaility', + }, + { + type: modules.includes('custom-roles') ? 'success' : 'neutral', + title: 'Custom_roles', + }, + { + type: modules.includes('auditing') ? 'success' : 'neutral', + title: 'Message_audit', + }, + ]; + + const sortedFeatureSet = featureSet.sort((a, b) => { + if (a.type === 'success' && b.type !== 'success') { + return -1; + } + if (a.type !== 'success' && b.type === 'success') { + return 1; + } + return featureSet.indexOf(a) - featureSet.indexOf(b); + }); + + return sortedFeatureSet; + }; + + return ( + + + + + {getFeatureSet(activeModules, isEnterprise).map(({ type, title, infoText }, index) => ( + + + + {t(title)} + + {infoText && } + + ))} + + + + + {t('Compare_plans')} + + + + + ); +}; + +export default FeaturesCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/MACCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/MACCard.tsx new file mode 100644 index 0000000000000..cc2a2eb59d1e9 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/MACCard.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CardProps } from '../FeatureUsageCard'; +import PieGraphCard from '../PieGraphCard'; + +const MACCard = ({ value = 0, max }: { value: number; max: number }): ReactElement => { + const { t } = useTranslation(); + + const pieGraph = { + used: value, + total: max, + }; + + const nearLimit = pieGraph && pieGraph.used / pieGraph.total >= 0.8; + + const card: CardProps = { + title: t('Monthly_active_contacts'), + infoText: t('MAC_InfoText'), + showUpgradeButton: nearLimit, + upgradeButtonText: 'Buy_more', + }; + + return ; +}; + +export default MACCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx new file mode 100644 index 0000000000000..e9b026e57499a --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx @@ -0,0 +1,29 @@ +import type { ILicenseV3 } from '@rocket.chat/license'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import PlanCardPremium from './PlanCard/PlanCardPremium'; +import PlanCardTrial from './PlanCard/PlanCardTrial'; + +type LicenseLimits = { + activeUsers: { max: number; value?: number }; + monthlyActiveContacts: { max: number; value?: number }; +}; + +type PlanCardProps = { + licenseInformation: ILicenseV3['information']; + licenseLimits: LicenseLimits; +}; + +const PlanCard = ({ licenseInformation, licenseLimits }: PlanCardProps): ReactElement => { + const isTrial = licenseInformation.trial; + + switch (true) { + case isTrial: + return ; + default: + return ; + } +}; + +export default PlanCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx new file mode 100644 index 0000000000000..1f6cefdb76469 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx @@ -0,0 +1,22 @@ +import { Box, Icon, Palette } from '@rocket.chat/fuselage'; +import { Card, CardBody, CardColSection, CardTitle } from '@rocket.chat/ui-client'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; + +const PlanCardBase = ({ name, children }: { name: string; children: ReactNode }): ReactElement => { + return ( + + + + {name} + + + + {children} + + + + ); +}; + +export default PlanCardBase; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx new file mode 100644 index 0000000000000..6a8d00ad8b4d9 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx @@ -0,0 +1,23 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import PlanCardBase from './PlanCardBase'; + +const PlanCardCommunity = (): ReactElement => { + const { t } = useTranslation(); + + return ( + + + {t('free_per_month_user')} + + + {t('Self_managed_hosting')} + + + ); +}; + +export default PlanCardCommunity; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx new file mode 100644 index 0000000000000..767c1c37e30c9 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx @@ -0,0 +1,69 @@ +import { Box, Icon, Skeleton } from '@rocket.chat/fuselage'; +import type { ILicenseV3 } from '@rocket.chat/license'; +import { ExternalLink } from '@rocket.chat/ui-client'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { useFormatDate } from '../../../../../../hooks/useFormatDate'; +import { useIsSelfHosted } from '../../../../../../hooks/useIsSelfHosted'; +import { CONTACT_SALES_LINK } from '../../../utils/links'; +import PlanCardBase from './PlanCardBase'; + +type LicenseLimits = { + activeUsers: { max: number; value?: number }; + monthlyActiveContacts: { max: number; value?: number }; +}; + +type PlanCardProps = { + licenseInformation: ILicenseV3['information']; + licenseLimits: LicenseLimits; +}; + +const PlanCardPremium = ({ licenseInformation, licenseLimits }: PlanCardProps): ReactElement => { + const { t } = useTranslation(); + const { isSelfHosted, isLoading } = useIsSelfHosted(); + const formatDate = useFormatDate(); + + const planName = licenseInformation.tags?.[0]?.name ?? ''; + + const isAutoRenew = licenseInformation.autoRenew; + const { visualExpiration } = licenseInformation; + + return ( + + {licenseLimits?.activeUsers.max === Infinity || + (licenseLimits?.monthlyActiveContacts.max === Infinity && ( + + + {licenseLimits?.activeUsers.max === Infinity && + licenseLimits?.monthlyActiveContacts.max === Infinity && + t('Unlimited_seats_MACs')} + {licenseLimits?.activeUsers.max === Infinity && licenseLimits?.monthlyActiveContacts.max !== Infinity && t('Unlimited_seats')} + {licenseLimits?.activeUsers.max !== Infinity && licenseLimits?.monthlyActiveContacts.max === Infinity && t('Unlimited_MACs')} + + ))} + + + + {isAutoRenew ? ( + t('Renews_DATE', { date: formatDate(visualExpiration) }) + ) : ( + + Contact sales to check plan renew date. + + )} + + + {!isLoading ? ( + + {isSelfHosted ? t('Self_managed_hosting') : t('Cloud_hosting')} + + ) : ( + + )} + + ); +}; + +export default PlanCardPremium; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx new file mode 100644 index 0000000000000..588dad4f3e964 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx @@ -0,0 +1,61 @@ +import { Box, Button, Tag } from '@rocket.chat/fuselage'; +import type { ILicenseV3 } from '@rocket.chat/license'; +import { ExternalLink } from '@rocket.chat/ui-client'; +import differenceInDays from 'date-fns/differenceInDays'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { CONTACT_SALES_LINK, DOWNGRADE_LINK, TRIAL_LINK } from '../../../utils/links'; +import UpgradeButton from '../../UpgradeButton'; +import PlanCardBase from './PlanCardBase'; + +type PlanCardProps = { + licenseInformation: ILicenseV3['information']; +}; + +const PlanCardTrial = ({ licenseInformation }: PlanCardProps): ReactElement => { + const { t } = useTranslation(); + + const planName = licenseInformation.tags?.[0]?.name ?? ''; + const isSalesAssisted = licenseInformation.grantedBy?.method !== 'self-service' || true; + const { visualExpiration } = licenseInformation; + + const trialDaysLeft = differenceInDays(new Date(visualExpiration), new Date()); + + return ( + + + + {t('Trial_active')} {t('n_days_left', { n: trialDaysLeft })} + + + {isSalesAssisted ? ( + + Contact sales to finish your purchase and avoid + downgrade consequences. + + ) : ( + + Finish your purchase to avoid downgrade consequences. + + )} + + + + Why has a trial been applied to this workspace? + + + {isSalesAssisted ? ( + + ) : ( + + )} + + + ); +}; + +export default PlanCardTrial; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/SeatsCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/SeatsCard.tsx new file mode 100644 index 0000000000000..94081580fbecb --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/SeatsCard.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CardProps } from '../FeatureUsageCard'; +import PieGraphCard from '../PieGraphCard'; + +type SeatsCardProps = { + value: number; + max: number; +}; + +const SeatsCard = ({ value, max }: SeatsCardProps): ReactElement => { + const { t } = useTranslation(); + + const pieGraph = { + used: value, + total: max, + }; + + const nearLimit = pieGraph && pieGraph.used / pieGraph.total >= 0.8; + + const card: CardProps = { + title: t('Seats'), + infoText: t('Seats_InfoText'), + showUpgradeButton: nearLimit, + upgradeButtonText: 'Buy_more', + }; + + return ; +}; + +export default SeatsCard; diff --git a/apps/meteor/client/views/admin/subscription/hooks/useCheckoutUrl.ts b/apps/meteor/client/views/admin/subscription/hooks/useCheckoutUrl.ts new file mode 100644 index 0000000000000..56ef0396e93e2 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/hooks/useCheckoutUrl.ts @@ -0,0 +1,20 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; + +import { useExternalLink } from '../../../../hooks/useExternalLink'; +import { CONTACT_SALES_LINK } from '../utils/links'; + +export const useCheckoutUrlAction = () => { + const getCheckoutUrl = useEndpoint('GET', '/v1/cloud.checkoutUrl'); + const handleExternalLink = useExternalLink(); + + return useMutation({ + mutationFn: async () => { + const { url } = await getCheckoutUrl(); + handleExternalLink(url); + }, + onError: () => { + handleExternalLink(CONTACT_SALES_LINK); + }, + }); +}; diff --git a/apps/meteor/client/views/admin/subscription/hooks/useWorkspaceSync.ts b/apps/meteor/client/views/admin/subscription/hooks/useWorkspaceSync.ts new file mode 100644 index 0000000000000..88f49470ebfc0 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/hooks/useWorkspaceSync.ts @@ -0,0 +1,25 @@ +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +export const useWorkspaceSync = () => { + const { t } = useTranslation(); + const cloudSync = useEndpoint('POST', '/v1/cloud.syncWorkspace'); + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutation({ + mutationFn: () => cloudSync(), + onSuccess: () => { + dispatchToastMessage({ + type: 'success', + message: t('Sync_success'), + }); + }, + onError: (error) => { + dispatchToastMessage({ + type: 'error', + message: error, + }); + }, + }); +}; diff --git a/apps/meteor/client/views/admin/subscription/utils/links.ts b/apps/meteor/client/views/admin/subscription/utils/links.ts new file mode 100644 index 0000000000000..3b32bf163412c --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/utils/links.ts @@ -0,0 +1,4 @@ +export const CONTACT_SALES_LINK = 'https://go.rocket.chat/i/contact-sales-product'; +export const PRICING_LINK = 'https://go.rocket.chat/i/pricing-product'; +export const DOWNGRADE_LINK = 'https://go.rocket.chat/i/docs-downgrade'; +export const TRIAL_LINK = 'https://go.rocket.chat/i/docs-trial'; diff --git a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx index 9307a2596a717..efd1846c09d34 100644 --- a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx +++ b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx @@ -2,12 +2,12 @@ import { Box, Icon, Skeleton, Scrollable } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import { useAnalyticsObject } from './hooks/useAnalyticsObject'; +import { useStatistics } from '../../hooks/useStatistics'; const AnalyticsReports = () => { const t = useTranslation(); - const { data, isLoading, isSuccess, isError } = useAnalyticsObject(); + const { data, isLoading, isSuccess, isError } = useStatistics(); return ( diff --git a/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts b/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts deleted file mode 100644 index 8aad0e605964e..0000000000000 --- a/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; - -export const useAnalyticsObject = () => { - const getAnalytics = useEndpoint('GET', '/v1/statistics'); - - return useQuery(['analytics'], () => getAnalytics({}), { staleTime: 10 * 60 * 1000 }); -}; diff --git a/apps/meteor/client/views/admin/customUserStatus/hooks/useActiveConnections.ts b/apps/meteor/client/views/hooks/useActiveConnections.ts similarity index 100% rename from apps/meteor/client/views/admin/customUserStatus/hooks/useActiveConnections.ts rename to apps/meteor/client/views/hooks/useActiveConnections.ts diff --git a/apps/meteor/client/views/hooks/useStatistics.ts b/apps/meteor/client/views/hooks/useStatistics.ts new file mode 100644 index 0000000000000..9b1a589253da5 --- /dev/null +++ b/apps/meteor/client/views/hooks/useStatistics.ts @@ -0,0 +1,12 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +type UseStatisticsOptions = { + refresh?: 'false' | 'true'; +}; + +export const useStatistics = ({ refresh }: UseStatisticsOptions = { refresh: 'false' }) => { + const getStatistics = useEndpoint('GET', '/v1/statistics'); + + return useQuery(['analytics'], () => getStatistics({ refresh }), { staleTime: 10 * 60 * 1000 }); +}; diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index abfa9da251dcc..b01be94474f68 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -10,9 +10,8 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial') as boolean; const { data: licensesData, isSuccess: isSuccessLicense } = useLicense(); - const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); + const { isRegistered, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); - const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; const hasValidLicense = Boolean(licensesData?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; @@ -21,7 +20,7 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({ - registered, + registered: isRegistered || false, hasValidLicense, hadExpiredTrials, isTrial, diff --git a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx deleted file mode 100644 index 804893ae84585..0000000000000 --- a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage'; -import colors from '@rocket.chat/fuselage-tokens/colors'; -import { Card, CardBody, CardCol, CardFooter, CardTitle } from '@rocket.chat/ui-client'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import React from 'react'; - -import { useExternalLink } from '../../../../../client/hooks/useExternalLink'; -import UsagePieGraph from '../../../../../client/views/admin/info/UsagePieGraph'; -import { useRequestSeatsLink } from '../users/useRequestSeatsLink'; -import type { SeatCapProps } from '../users/useSeatsCap'; - -type SeatsCardProps = { - seatsCap: SeatCapProps | undefined; -}; - -const SeatsCard = ({ seatsCap }: SeatsCardProps): ReactElement => { - const t = useTranslation(); - const requestSeatsLink = useRequestSeatsLink(); - const handleExternalLink = useExternalLink(); - - const seatsLeft = seatsCap && Math.max(seatsCap.maxActiveUsers - seatsCap.activeUsers, 0); - - const isNearLimit = seatsCap && seatsCap.activeUsers / seatsCap.maxActiveUsers >= 0.8; - - const color = isNearLimit ? colors.r500 : undefined; - - return ( - - {t('Seats_usage')} - - - - {!seatsCap ? ( - - ) : ( - {t('Seats_Available', { seatsLeft })}} - used={seatsCap.activeUsers} - total={seatsCap.maxActiveUsers} - size={140} - color={color} - /> - )} - - - - - - - - - - ); -}; - -export default SeatsCard; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 8656ffb28a9f4..f582dae7d195f 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -4176,7 +4176,7 @@ "Reactions": "Reactions", "Read_by": "Read by", "Read_only": "Read Only", - "Read_Receipts": "Read Receipts", + "Read_Receipts": "Read receipts", "Readability": "Readability", "This_room_is_read_only": "This room is read only", "Only_people_with_permission_can_send_messages_here": "Only people with permission can send messages here", @@ -4194,6 +4194,7 @@ "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", @@ -6114,5 +6115,68 @@ "unread_messages_counter": "{{count}} unread message", "unread_messages_counter_plural": "{{count}} unread messages", "Premium": "Premium", - "Premium_capability": "Premium capability" + "Premium_capability": "Premium capability", + "Subscription": "Subscription", + "Manage_subscription": "Manage subscription", + "ActiveSessionsPeak": "Active sessions peak", + "ActiveSessionsPeak_InfoText": "Highest amount of active connections in the past 30 days", + "ActiveSessions": "Active sessions", + "ActiveSessions_available": "sessions available", + "Monthly_active_contacts": "Monthly active contacts", + "Upgrade": "Upgrade", + "Seats": "Seats", + "Marketplace_apps": "Marketplace apps", + "Private_apps": "Private apps", + "Finish_your_purchase_trial": "Finish your purchase to avoid <1>downgrade consequences.", + "Contact_sales_trial": "Contact sales to finish your purchase and avoid <1>downgrade consequences.", + "Why_has_a_trial_been_applied_to_this_workspace": "<0>Why has a trial been applied to this workspace?", + "Compare_plans": "Compare plans", + "n_days_left": "{{n}} days left", + "Contact_sales": "Contact sales", + "Finish_purchase": "Finish purchase", + "Self_managed_hosting": "Self-managed hosting", + "Cloud_hosting": "Rocket.Chat cloud hosting", + "free_per_month_user": "$0 per month/user", + "Trial_active": "Trial active", + "Contact_sales_renew_date": "<0>Contact sales to check plan renew date", + "Renews_DATE": "Renews {{date}}", + "UpgradeToGetMore_Headline": "Upgrade to get more", + "UpgradeToGetMore_Subtitle": "Supercharge your workspace with advanced capabilities.", + "UpgradeToGetMore_scalability_Title": "High scalabaility", + "UpgradeToGetMore_scalability_Body": "Improve efficiency, decrease costs and increase concurrent users usage by switching from monolithic to microservices or multi-instance.", + "UpgradeToGetMore_accessibility-certification_Title": "WCAG 2.1 and BITV 2.0", + "UpgradeToGetMore_accessibility-certification_Body": "Comply with WCAG and BITV standards with Rocket.Chat's accessibility program.", + "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", + "UpgradeToGetMore_engagement-dashboard_Body": "Gain insights into user, message, and channel usage through the engagement dashboard.", + "UpgradeToGetMore_oauth-enterprise_Title": "Advanced authentication", + "UpgradeToGetMore_oauth-enterprise_Body": "Ensure proper access permissions through LDAP/SAML/Oauth with group roles mapping, channel subscription, auto logout and more.", + "UpgradeToGetMore_custom-roles_Title": "Custom roles", + "UpgradeToGetMore_custom-roles_Body": "Ensure a safe and productive work environment by setting specific roles and permissions for people in your workspace.", + "UpgradeToGetMore_auditing_Title": "Message auditing", + "UpgradeToGetMore_auditing_Body": "Audit conversations in one single place to ensure communication quality with customers, suppliers, and internal teams.", + "Seats_InfoText": "Each unique user occupies one seat. Deactivated users do not occupy seats. Total number of seats is defined by active license type.", + "CountSeats_InfoText": "Each unique user occupies one seat. Deactivated users do not occupy seats.", + "MAC_InfoText": "(MAC) the number of unique omnichannel contacts engaged with during the billing month.", + "CountMAC_InfoText": "(MAC) the number of unique omnichannel contacts engaged with during the calendar month.", + "ActiveSessions_InfoText": "Total concurrent connections. A single user can be connected multiple times. User presence service is disabled at 200 or more to prevent performance issues.", + "Apps_InfoText": "Community allows up to 3 private apps and 5 marketplace apps to be enabled", + "Remove_RocketChat_Watermark_InfoText": "Watermark is automatically removed when a paid license is active.", + "Remove_RocketChat_Watermark": "Remove Rocket.Chat watermark", + "High_scalabaility": "High scalabaility", + "Premium_and_unlimited_apps": "Premium and unlimited apps", + "Message_audit": "Message auditing", + "Premium_omnichannel_capabilities": "Premium omnichannel capabilities", + "Video_call_manager": "Video call manager", + "Unlimited_push_notifications": "Unlimited push notifications", + "Buy_more": "Buy more", + "Upgrade_to_Pro": "Upgrade to Pro", + "Start_Enterprise_trial": "Start Enterprise trial", + "Sync_license_update": "Sync license update", + "Sync_license_update_Callout_Title": "We're updating your license", + "Sync_license_update_Callout": "If you don't notice any changes in your workspace within a few minutes, sync the license update.", + "Includes": "Includes", + "Unlock_premium_capabilities": "Unlock premium capabilities", + "Unlimited_seats": "Unlimited seats", + "Unlimited_MACs": "Unlimited MACs", + "Unlimited_seats_MACs": "Unlimited seats and MACs" } diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts index 3d2472cc84d0b..aa31705afc67e 100644 --- a/apps/meteor/server/settings/setup-wizard.ts +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -1354,5 +1354,15 @@ export const createSetupWSettings = () => }, secret: true, }); + await this.add('Cloud_Billing_Url', 'https://billing.rocket.chat', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); }); }); diff --git a/ee/packages/license/src/definition/LicenseModule.ts b/ee/packages/license/src/definition/LicenseModule.ts index a67a3fd54cb05..32c7097b4d2cc 100644 --- a/ee/packages/license/src/definition/LicenseModule.ts +++ b/ee/packages/license/src/definition/LicenseModule.ts @@ -16,4 +16,6 @@ export type LicenseModule = | 'videoconference-enterprise' | 'message-read-receipt' | 'outlook-calendar' - | 'hide-watermark'; + | 'hide-watermark' + | 'custom-roles' + | 'accessibility-certification'; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index a59f4544e5db1..aaabf255812cb 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -422,7 +422,6 @@ export class LicenseManager extends Emitter { (await Promise.all( globalLimitKinds .map((limitKey) => [limitKey, getLicenseLimit(license, limitKey)] as const) - .filter(([, max]) => max >= 0 && max < Infinity) .map(async ([limitKey, max]) => { return [ limitKey, diff --git a/packages/rest-typings/src/v1/cloud.ts b/packages/rest-typings/src/v1/cloud.ts index 90664dcc243be..f63d3e3d330d2 100644 --- a/packages/rest-typings/src/v1/cloud.ts +++ b/packages/rest-typings/src/v1/cloud.ts @@ -88,4 +88,7 @@ export type CloudEndpoints = { '/v1/cloud.registrationStatus': { GET: () => { registrationStatus: CloudRegistrationStatus }; }; + '/v1/cloud.syncWorkspace': { + POST: () => { success: boolean }; + }; }; diff --git a/packages/ui-client/src/components/Card/CardTitle.tsx b/packages/ui-client/src/components/Card/CardTitle.tsx index 8d7ef374334fc..ac31848a06897 100644 --- a/packages/ui-client/src/components/Card/CardTitle.tsx +++ b/packages/ui-client/src/components/Card/CardTitle.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import type { FC } from 'react'; const CardTitle: FC = ({ children }) => ( - + {children} ); diff --git a/packages/ui-client/src/components/FramedIcon.tsx b/packages/ui-client/src/components/FramedIcon.tsx new file mode 100644 index 0000000000000..6fa2b230c661f --- /dev/null +++ b/packages/ui-client/src/components/FramedIcon.tsx @@ -0,0 +1,37 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys } from '@rocket.chat/icons'; +import type { FC } from 'react'; + +type Variant = 'danger' | 'info' | 'success' | 'warning' | 'neutral'; + +type ColorMapType = { + [key in Variant]: { + color: string; + bg: string; + }; +}; + +const colorMap: ColorMapType = { + danger: { color: 'status-font-on-danger', bg: 'status-background-danger' }, + info: { color: 'status-font-on-info', bg: 'status-background-info' }, + success: { color: 'status-font-on-success', bg: 'status-background-success' }, + warning: { color: 'status-font-on-warning', bg: 'status-background-warning' }, + neutral: { color: 'font-secondary-info', bg: 'surface-tint' }, +}; + +const getColors = (type: Variant) => colorMap[type] || colorMap.neutral; + +type FramedIconProps = { + type: Variant; + icon: Keys; +}; + +export const FramedIcon: FC = ({ type, icon }) => { + const { color, bg } = getColors(type); + + return ( + + + + ); +}; diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index 3de4bf411a5eb..1b84d799c7b08 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -11,3 +11,4 @@ export * from './Card'; export * from './Header'; export * from './MultiSelectCustom/MultiSelectCustom'; export * from './FeaturePreview/FeaturePreview'; +export * from './FramedIcon';