snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CancelSubscriptionModal } from './CancelSubscriptionModal';
import { DOWNGRADE_LINK } from '../utils/links';
it('should display plan name in the title', async () => {
const confirmFn = jest.fn();
render(<CancelSubscriptionModal planName='Starter' onConfirm={confirmFn} onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Cancel__planName__subscription: 'Cancel {{planName}} subscription',
})
.build(),
});
expect(screen.getByText('Cancel Starter subscription')).toBeInTheDocument();
});
it('should have link to downgrade docs', async () => {
render(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Cancel__planName__subscription: 'Cancel {{planName}} subscription',
Cancel_subscription_message:
'<strong>This workspace will downgrage to Community and lose free access to premium capabilities.</strong><br/><br/> While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace apps <4>and other capabilities</4>.',
})
.build(),
});
expect(screen.getByRole('link', { name: 'and other capabilities' })).toHaveAttribute('href', DOWNGRADE_LINK);
});
it('should call onConfirm when confirm button is clicked', async () => {
const confirmFn = jest.fn();
render(<CancelSubscriptionModal planName='Starter' onConfirm={confirmFn} onCancel={jest.fn()} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Cancel_subscription' }));
expect(confirmFn).toHaveBeenCalled();
});
it('should call onCancel when "Dont cancel" button is clicked', async () => {
const cancelFn = jest.fn();
render(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={cancelFn} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Dont_cancel' }));
expect(cancelFn).toHaveBeenCalled();
});
it('should call onCancel when close button is clicked', async () => {
const cancelFn = jest.fn();
render(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={cancelFn} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(cancelFn).toHaveBeenCalled();
});

View File

@@ -0,0 +1,34 @@
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import { DOWNGRADE_LINK } from '../utils/links';
export type CancelSubscriptionModalProps = {
planName: string;
onConfirm(): void;
onCancel(): void;
};
export const CancelSubscriptionModal = ({ planName, onCancel, onConfirm }: CancelSubscriptionModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='danger'
title={t('Cancel__planName__subscription', { planName })}
icon={null}
confirmText={t('Cancel_subscription')}
cancelText={t('Dont_cancel')}
onConfirm={onConfirm}
onCancel={onCancel}
>
<Trans i18nKey='Cancel_subscription_message' t={t}>
<strong>This workspace will downgrade to Community and lose free access to premium capabilities.</strong>
<br />
<br />
While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace
apps and <ExternalLink to={DOWNGRADE_LINK}>other capabilities</ExternalLink>.
</Trans>
</GenericModal>
);
};

View File

@@ -0,0 +1,32 @@
import { Card, CardControls, CardTitle } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { memo } from 'react';
import InfoTextIconModal from './InfoTextIconModal';
export type FeatureUsageCardProps = {
children?: ReactNode;
card: CardProps;
};
export type CardProps = {
title: string;
infoText?: ReactNode;
upgradeButton?: ReactNode;
};
const FeatureUsageCard = ({ children, card }: FeatureUsageCardProps) => {
const { title, infoText, upgradeButton } = card;
return (
<Card height='full'>
<CardTitle>
{title} {infoText && <InfoTextIconModal title={title} infoText={infoText} />}
</CardTitle>
{children}
{upgradeButton && <CardControls>{upgradeButton}</CardControls>}
</Card>
);
};
export default memo(FeatureUsageCard);

View File

@@ -0,0 +1,14 @@
import { Box, CardBody } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type FeatureUsageCardBodyProps = { justifyContent?: 'flex-start' | 'center'; children: ReactNode };
const FeatureUsageCardBody = ({ justifyContent = 'center', children }: FeatureUsageCardBodyProps) => (
<CardBody>
<Box height='full' width='full' display='flex' alignItems='center' justifyContent={justifyContent} flexDirection='column'>
{children}
</Box>
</CardBody>
);
export default FeatureUsageCardBody;

View File

@@ -0,0 +1,32 @@
import { IconButton } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export type InfoTextIconModalProps = {
title: string;
infoText: ReactNode;
};
const InfoTextIconModal = ({ title, infoText }: InfoTextIconModalProps) => {
const setModal = useSetModal();
const { t } = useTranslation();
const handleInfoClick = () => {
if (!infoText) {
setModal(null);
return;
}
setModal(
<GenericModal icon='info' title={title} onClose={() => setModal(null)}>
{infoText}
</GenericModal>,
);
};
return <IconButton icon='info' aria-label={t('Click_here_for_more_info')} mini onClick={() => handleInfoClick()} />;
};
export default memo(InfoTextIconModal);

View File

@@ -0,0 +1,27 @@
import { Button } from '@rocket.chat/fuselage';
import type { ButtonProps } from '@rocket.chat/fuselage/dist/components/Button/Button';
import { memo } from 'react';
import { useExternalLink } from '../../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../hooks/useCheckoutUrl';
const UpgradeButton = ({
children,
target = '_blank',
action,
...props
}: Partial<ButtonProps> & {
target: string;
action: string;
}) => {
const handleOpenLink = useExternalLink();
const url = useCheckoutUrl()({ target, action });
return (
<Button icon='new-window' onClick={() => handleOpenLink(url)} {...props}>
{children}
</Button>
);
};
export default memo(UpgradeButton);

View File

@@ -0,0 +1,79 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Button, ButtonGroup, CardGrid } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericCard } from '../../../../components/GenericCard';
import { useExternalLink } from '../../../../hooks/useExternalLink';
import { PRICING_LINK } from '../utils/links';
export type UpgradeToGetMoreProps = {
activeModules: string[];
isEnterprise: boolean;
children: ReactNode;
};
const enterpriseModules = [
'scalability',
'accessibility-certification',
'engagement-dashboard',
'oauth-enterprise',
'custom-roles',
'auditing',
];
const UpgradeToGetMore = ({ activeModules, children }: UpgradeToGetMoreProps) => {
const { t } = useTranslation();
const handleOpenLink = useExternalLink();
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 (
<ButtonGroup large vertical>
{children}
</ButtonGroup>
);
}
return (
<Box width='full' padding={8} marginBlockStart={40}>
<States>
<StatesIcon name='rocket' />
<StatesTitle>{t('UpgradeToGetMore_Headline')}</StatesTitle>
<StatesSubtitle>{t('UpgradeToGetMore_Subtitle')}</StatesSubtitle>
</States>
<CardGrid
breakpoints={{
xs: 4,
sm: 4,
md: 4,
lg: 6,
xl: 4,
padding: 8,
}}
>
{upgradeModules.map((card, index) => {
return <GenericCard key={index} icon='check' type='success' height='full' {...card} />;
})}
</CardGrid>
<Box paddingBlockStart={24}>
<ButtonGroup large vertical>
<Button icon='new-window' onClick={() => handleOpenLink(PRICING_LINK)} role='link'>
{t('Compare_plans')}
</Button>
{children}
</ButtonGroup>
</Box>
</Box>
);
};
export default memo(UpgradeToGetMore);

View File

@@ -0,0 +1,71 @@
import colorTokens from '@rocket.chat/fuselage-tokens/dist/colors.json';
import type { StoryObj, Meta } from '@storybook/react';
import type { ComponentProps } from 'react';
import UsagePieGraph from './UsagePieGraph';
import { useAutoSequence } from '../../../../stories/hooks/useAutoSequence';
export default {
component: UsagePieGraph,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof UsagePieGraph>;
export const Example = {
args: {
total: 321,
used: 123,
size: 128,
label: 'Example label',
color: colorTokens.p500,
},
};
export const Unlimited = {
args: {
used: 123,
size: 128,
label: 'Example label',
color: colorTokens.p500,
},
};
export const Animated: StoryObj<Pick<ComponentProps<typeof UsagePieGraph>, 'size' | 'label'>> = {
render: (args) => {
const props = useAutoSequence([
{
total: 100,
used: 0,
color: colorTokens.g500,
},
{
total: 100,
used: 25,
color: colorTokens.b500,
},
{
total: 100,
used: 50,
color: colorTokens.y500,
},
{
total: 100,
used: 75,
color: colorTokens.o500,
},
{
total: 100,
used: 100,
color: colorTokens.r500,
},
]);
return <UsagePieGraph {...props} {...args} />;
},
args: {
size: 128,
label: 'Example label',
},
};

View File

@@ -0,0 +1,109 @@
import type { DatumId } from '@nivo/pie';
import { Pie } from '@nivo/pie';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { CSSProperties, ReactNode } from 'react';
import { useMemo, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocalePercentage } from '../../../../hooks/useLocalePercentage';
type GraphColorsReturn = { [key: string]: string };
const graphColors = (color: CSSProperties['color']): GraphColorsReturn => ({
used: color || Palette.statusColor['status-font-on-success'].toString(),
free: Palette.stroke['stroke-extra-light'].toString(),
});
export type UsagePieGraphProps = {
used: number;
total: number;
label?: ReactNode;
color?: string;
size?: number;
};
type GraphData = Array<{
id: string;
label: string;
value: number;
}>;
const UsagePieGraph = ({ used = 0, total = 0, label, color, size = 140 }: UsagePieGraphProps) => {
const { t } = useTranslation();
const parsedData = useMemo(
(): GraphData => [
{
id: 'used',
label: 'used',
value: used,
},
{
id: 'free',
label: 'free',
value: total - used,
},
],
[total, used],
);
const getColor = useCallback(
(datum: { id: DatumId } | undefined) => {
if (!datum || typeof datum.id !== 'string') {
return '';
}
return graphColors(color)[datum.id];
},
[color],
);
const unlimited = total === 0;
const localePercentage = useLocalePercentage(total, used, 0);
return (
<Box display='flex' flexDirection='column' alignItems='center'>
<Box size={`x${size}`}>
<Box position='relative'>
<Pie
data={parsedData}
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
innerRadius={0.8}
colors={getColor}
width={size}
height={size}
enableArcLabels={false}
enableArcLinkLabels={false}
/>
<Box
display='flex'
alignItems='center'
justifyContent='center'
position='absolute'
fontScale='p2m'
style={{ left: 0, right: 0, top: 0, bottom: 0 }}
>
{unlimited ? '∞' : localePercentage}
</Box>
</Box>
</Box>
<Box is='span' fontScale='p2' color='font-secondary-info'>
{unlimited &&
t('used_limit_infinite', {
used,
})}
{!unlimited &&
t('used_limit', {
used,
limit: total,
})}
</Box>
{label && (
<Box is='span' marginBlockStart={4} color='font-secondary-info'>
{label}
</Box>
)}
</Box>
);
};
export default memo(UsagePieGraph);

View File

@@ -0,0 +1,71 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useActiveConnections } from '../../../../hooks/useActiveConnections';
import type { CardProps } from '../FeatureUsageCard';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
import UpgradeButton from '../UpgradeButton';
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 = () => {
const { t } = useTranslation();
const result = useActiveConnections();
const card: CardProps = {
title: t('ActiveSessions'),
infoText: t('ActiveSessions_InfoText'),
};
if (result.isPending || result.isError) {
return (
<FeatureUsageCard card={card}>
<FeatureUsageCardBody justifyContent='flex-start'>
<Skeleton variant='rect' width='x112' height='x224' />
</FeatureUsageCardBody>
</FeatureUsageCard>
);
}
const { total, used, available, exceedLimit } = getLimits(result.data);
return (
<FeatureUsageCard
card={{
...card,
...(exceedLimit && {
upgradeButton: (
<UpgradeButton target='active-session-card' action='upgrade' small>
{t('Upgrade')}
</UpgradeButton>
),
}),
}}
>
<FeatureUsageCardBody justifyContent='flex-start'>
<Box color='font-secondary-info' textAlign='center'>
<Box fontScale='h1' color={exceedLimit ? 'font-danger' : 'font-default'} marginBlockEnd={12}>
{used} / {total}
</Box>
{available} {t('ActiveSessions_available')}
</Box>
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default ActiveSessionsCard;

View File

@@ -0,0 +1,57 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useFormatDate } from '../../../../../hooks/useFormatDate';
import { useStatistics } from '../../../../hooks/useStatistics';
import type { CardProps } from '../FeatureUsageCard';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
import UpgradeButton from '../UpgradeButton';
const ActiveSessionsPeakCard = () => {
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'),
...(exceedLimit && {
upgradeButton: (
<UpgradeButton target='active-session-peak-card' action='upgrade' small>
{t('Upgrade')}
</UpgradeButton>
),
}),
};
if (isLoading || maxMonthlyPeakConnections === undefined) {
return (
<FeatureUsageCard card={card}>
<FeatureUsageCardBody justifyContent='flex-start'>
<Skeleton variant='rect' width='x112' height='x112' />
</FeatureUsageCardBody>
</FeatureUsageCard>
);
}
return (
<FeatureUsageCard card={card}>
<Box color='font-secondary-info' textAlign='center'>
<Box fontScale='h1' color={exceedLimit ? 'font-danger' : 'font-default'} marginBlockEnd={12}>
{used} / {total}
</Box>
{formatDate(new Date())}
</Box>
</FeatureUsageCard>
);
};
export default ActiveSessionsPeakCard;

View File

@@ -0,0 +1,84 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AppsUsageCard from './AppsUsageCard';
import { PRICING_LINK } from '../../../utils/links';
const appRoot = mockAppRoot().withTranslations('en', 'core', {
Apps_InfoText_limited:
'Community workspaces can enable up to {{marketplaceAppsMaxCount}} marketplace apps. Private apps can only be enabled in <1>premium plans</1>.',
});
it('should render a skeleton if no data', () => {
render(<AppsUsageCard />, { wrapper: appRoot.build() });
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
expect(screen.getByRole('presentation')).toBeInTheDocument();
});
it('should render data as progress bars', async () => {
render(<AppsUsageCard privateAppsLimit={{ value: 1, max: 3 }} marketplaceAppsLimit={{ value: 2, max: 5 }} />, {
wrapper: appRoot.build(),
});
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toHaveAttribute('aria-valuenow', '40');
expect(screen.getByText('2 / 5')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toHaveAttribute('aria-valuenow', '33');
expect(screen.getByText('1 / 3')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
// TODO: discover how to automatically unmount all modals after each test
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
});
it('should render an upgrade button if marketplace apps reached 80% of the limit', async () => {
render(<AppsUsageCard privateAppsLimit={{ value: 1, max: 3 }} marketplaceAppsLimit={{ value: 4, max: 5 }} />, {
wrapper: appRoot.build(),
});
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
// TODO: discover how to automatically unmount all modals after each test
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
});
it('should render a full progress bar with private apps disabled', async () => {
render(<AppsUsageCard privateAppsLimit={{ value: 0, max: 0 }} marketplaceAppsLimit={{ value: 2, max: 5 }} />, {
wrapper: appRoot.build(),
});
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toHaveAttribute('aria-valuenow', '40');
expect(screen.getByText('2 / 5')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toHaveAttribute('aria-valuenow', '100');
expect(screen.getByText('0 / 0')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
// TODO: discover how to automatically unmount all modals after each test
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
});

View File

@@ -0,0 +1,85 @@
import { Skeleton } from '@rocket.chat/fuselage';
import { ExternalLink } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import AppsUsageCardSection from './AppsUsageCardSection';
import { PRICING_LINK } from '../../../utils/links';
import type { CardProps } from '../../FeatureUsageCard';
import FeatureUsageCard from '../../FeatureUsageCard';
import FeatureUsageCardBody from '../../FeatureUsageCardBody';
import UpgradeButton from '../../UpgradeButton';
// Magic numbers
const marketplaceAppsMaxCountFallback = 5;
const privateAppsMaxCountFallback = 0;
const defaultWarningThreshold = 80;
export type AppsUsageCardProps = {
privateAppsLimit?: { value?: number; max: number };
marketplaceAppsLimit?: { value?: number; max: number };
};
const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCardProps) => {
const { t } = useTranslation();
if (!privateAppsLimit || !marketplaceAppsLimit) {
// FIXME: not accessible enough
return (
<FeatureUsageCard card={{ title: t('Apps') }}>
<FeatureUsageCardBody justifyContent='flex-start'>
<Skeleton variant='rect' width='x112' height='x224' role='presentation' />
</FeatureUsageCardBody>
</FeatureUsageCard>
);
}
const marketplaceAppsCount = marketplaceAppsLimit?.value || 0;
const marketplaceAppsMaxCount = marketplaceAppsLimit?.max || marketplaceAppsMaxCountFallback;
const marketplaceAppsPercentage = Math.round((marketplaceAppsCount / marketplaceAppsMaxCount) * 100) || 0;
const marketplaceAppsAboveWarning = marketplaceAppsPercentage >= defaultWarningThreshold;
const privateAppsCount = privateAppsLimit?.value || 0;
const privateAppsMaxCount = privateAppsLimit?.max || privateAppsMaxCountFallback;
const card: CardProps = {
title: t('Apps'),
infoText: (
<Trans
i18nKey='Apps_InfoText_limited'
values={{ marketplaceAppsMaxCount }}
components={{ 1: <ExternalLink to={PRICING_LINK}>premium plans</ExternalLink> }}
/>
),
...(marketplaceAppsAboveWarning && {
upgradeButton: (
<UpgradeButton target='app-usage-card' action='upgrade' small>
{t('Upgrade')}
</UpgradeButton>
),
}),
};
return (
<FeatureUsageCard card={card}>
<FeatureUsageCardBody justifyContent='flex-start'>
<AppsUsageCardSection
title={t('Marketplace_apps')}
appsCount={marketplaceAppsCount}
appsMaxCount={marketplaceAppsMaxCount}
warningThreshold={defaultWarningThreshold}
/>
<AppsUsageCardSection
title={t('Private_apps')}
tip={privateAppsMaxCount === 0 ? t('Private_apps_premium_message') : undefined}
appsCount={privateAppsCount}
appsMaxCount={privateAppsMaxCount}
warningThreshold={defaultWarningThreshold}
/>
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default AppsUsageCard;

View File

@@ -0,0 +1,40 @@
import { Box, ProgressBar } from '@rocket.chat/fuselage';
import { useId, type ReactNode } from 'react';
export type AppsUsageCardSectionProps = {
title: ReactNode;
tip?: string;
appsCount: number;
appsMaxCount: number;
warningThreshold: number;
};
const AppsUsageCardSection = ({ title, tip, appsCount, appsMaxCount, warningThreshold }: AppsUsageCardSectionProps) => {
const percentage = appsMaxCount === 0 ? 100 : Math.round((appsCount * 100) / appsMaxCount);
const warningThresholdCrossed = percentage >= warningThreshold;
const labelId = useId();
return (
<Box fontScale='c1' marginBlock={12} title={tip} display='flex' flexDirection='column' width='100%'>
<Box display='flex' flexGrow='1' justifyContent='space-between' marginBlockEnd={4}>
<div id={labelId}>{title}</div>
<Box color={warningThresholdCrossed ? 'status-font-on-danger' : 'status-font-on-success'}>
{appsCount} / {appsMaxCount}
</Box>
</Box>
<ProgressBar
percentage={percentage}
variant={warningThresholdCrossed ? 'danger' : 'success'}
role='progressbar'
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
/>
</Box>
);
};
export default AppsUsageCardSection;

View File

@@ -0,0 +1 @@
export { default } from './AppsUsageCard';

View File

@@ -0,0 +1,28 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
export type CountMACCardProps = { macsCount: number };
const CountMACCard = ({ macsCount }: CountMACCardProps) => {
const { t } = useTranslation();
return (
<FeatureUsageCard
card={{
title: t('Monthly_active_contacts'),
infoText: t('CountMAC_InfoText'),
}}
>
<FeatureUsageCardBody>
<Box fontScale='h1'>
<Icon color='font-annotation' name='headset' size={40} marginInlineEnd={4} />
{macsCount}
</Box>
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default CountMACCard;

View File

@@ -0,0 +1,28 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
export type CountSeatsCardProps = { activeUsers: number };
const CountSeatsCard = ({ activeUsers }: CountSeatsCardProps) => {
const { t } = useTranslation();
return (
<FeatureUsageCard
card={{
title: t('Seats'),
infoText: t('CountSeats_InfoText'),
}}
>
<FeatureUsageCardBody>
<Box fontScale='h1' display='flex' alignItems='center' justifyContent='center'>
<Icon color='font-annotation' name='user' size={40} marginInlineEnd={4} />
{activeUsers}
</Box>
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default CountSeatsCard;

View File

@@ -0,0 +1,90 @@
import { Box, Card, CardBody, CardControls, CardTitle, FramedIcon } from '@rocket.chat/fuselage';
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from 'react-i18next';
import { PRICING_LINK } from '../../utils/links';
import InfoTextIconModal from '../InfoTextIconModal';
type FeatureSet = {
success?: boolean;
neutral?: boolean;
title: string;
infoText?: string;
};
export type FeaturesCardProps = {
activeModules: string[];
isEnterprise: boolean;
};
const getFeatureSet = (modules: string[], isEnterprise: boolean): FeatureSet[] => {
const featureSet: FeatureSet[] = [
{
success: isEnterprise,
title: 'Premium_and_unlimited_apps',
},
{
success: isEnterprise,
title: 'Premium_omnichannel_capabilities',
},
{
success: isEnterprise,
title: 'Unlimited_push_notifications',
},
{
success: modules.includes('videoconference-enterprise'),
title: 'Video_call_manager',
},
{
success: modules.includes('hide-watermark'),
title: 'Remove_RocketChat_Watermark',
infoText: 'Remove_RocketChat_Watermark_InfoText',
},
{
success: modules.includes('scalability'),
title: 'High_scalability',
},
{
success: modules.includes('custom-roles'),
title: 'Custom_roles',
},
{
success: modules.includes('auditing'),
title: 'Message_audit',
},
];
// eslint-disable-next-line no-nested-ternary
return featureSet.sort(({ success: a }, { success: b }) => (a === b ? 0 : a ? -1 : 1));
};
const FeaturesCard = ({ activeModules, isEnterprise }: FeaturesCardProps) => {
const { t } = useTranslation();
const isSmall = useMediaQuery('(min-width: 1180px)');
return (
<Card height='full'>
<CardTitle>{!isEnterprise ? t('Unlock_premium_capabilities') : t('Includes')}</CardTitle>
<CardBody>
<Box display='flex' flexWrap='wrap' justifyContent='space-between' flexDirection={isSmall ? 'row' : 'column'}>
{getFeatureSet(activeModules, isEnterprise).map(({ success, title, infoText }, index) => (
<Box key={`feature_${index}`} display='flex' alignItems='center' marginBlockEnd={4} width={isSmall ? '50%' : 'full'}>
<FramedIcon success={success} icon={success ? 'check' : 'lock'} />
<Box is='p' fontScale='p2' marginInlineStart={12} marginInlineEnd={2} color='font-secondary-info'>
{t(title)}
</Box>
{infoText && <InfoTextIconModal title={t(title)} infoText={t(infoText)} />}
</Box>
))}
</Box>
</CardBody>
<CardControls>
<a target='_blank' rel='noopener noreferrer' href={PRICING_LINK}>
{t('Compare_plans')}
</a>
</CardControls>
</Card>
);
};
export default FeaturesCard;

View File

@@ -0,0 +1,60 @@
import { Palette } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import type { CardProps } from '../FeatureUsageCard';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
import UpgradeButton from '../UpgradeButton';
import UsagePieGraph from '../UsagePieGraph';
export type MACCardProps = { value: number; max: number; hideManageSubscription?: boolean };
const MACCard = ({ value = 0, max, hideManageSubscription }: MACCardProps) => {
const { t } = useTranslation();
const pieGraph = {
used: value,
total: max,
};
const nearLimit = pieGraph && pieGraph.used > 0 && pieGraph.used / pieGraph.total >= 0.8;
const macLeft = pieGraph.total - pieGraph.used;
const shouldShowBuyMore = !hideManageSubscription && nearLimit;
const shouldShowUpgrade = hideManageSubscription && nearLimit;
const card: CardProps = {
title: t('Monthly_active_contacts'),
infoText: t('MAC_InfoText'),
...(shouldShowBuyMore && {
upgradeButton: (
<UpgradeButton target='mac-card' action='buy_more' small>
{t('Buy_more')}
</UpgradeButton>
),
...(shouldShowUpgrade && {
upgradeButton: (
<UpgradeButton target='mac-card' action='upgrade' small>
{t('Upgrade')}
</UpgradeButton>
),
}),
}),
};
const color = nearLimit ? Palette.statusColor['status-font-on-danger'].toString() : undefined;
const message = macLeft > 0 ? t('MAC_Available', { macLeft }) : undefined;
return (
<FeatureUsageCard card={card}>
<FeatureUsageCardBody>
<UsagePieGraph label={message} used={pieGraph.used} total={pieGraph.total} color={color} />
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default MACCard;

View File

@@ -0,0 +1,30 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import PlanCardCommunity from './PlanCard/PlanCardCommunity';
import PlanCardPremium from './PlanCard/PlanCardPremium';
import PlanCardTrial from './PlanCard/PlanCardTrial';
type LicenseLimits = {
activeUsers: { max: number; value?: number };
};
export type PlanCardProps = {
license?: ILicenseV3;
licenseLimits: LicenseLimits;
};
const PlanCard = ({ license, licenseLimits }: PlanCardProps) => {
const isTrial = license?.information.trial;
if (!license) {
return <PlanCardCommunity />;
}
return isTrial ? (
<PlanCardTrial licenseInformation={license.information} />
) : (
<PlanCardPremium licenseInformation={license.information} licenseLimits={licenseLimits} />
);
};
export default PlanCard;

View File

@@ -0,0 +1,27 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { FilePreviewIcon } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { getFileExtension } from '../../../../../../../../lib/utils/getFileExtension';
import { formatBytes } from '../../../../../../../lib/utils/formatBytes';
const LicenseFilePreview = ({ selectedFile, handleRemoveFile }: { selectedFile: File; handleRemoveFile: () => void }) => {
const { t } = useTranslation();
return (
<Box display='flex' alignItems='center' padding={4} marginBlockEnd={8} borderRadius={4} borderWidth={1} borderColor='extra-light'>
<FilePreviewIcon format={getFileExtension(selectedFile.name)} />
<Box flexGrow={1} withTruncatedText marginInlineStart={8} display='flex' flexDirection='column'>
<Box fontScale='p2' color='info' withTruncatedText>
{selectedFile.name}
</Box>
<Box fontScale='c1' color='hint' textTransform='uppercase'>
{`${formatBytes(selectedFile.size, 2)} - ${getFileExtension(selectedFile.name)}`}
</Box>
</Box>
<IconButton icon='cross' tiny title={t('Remove_file')} onClick={handleRemoveFile} />
</Box>
);
};
export default LicenseFilePreview;

View File

@@ -0,0 +1,36 @@
import { Callout, Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
type LicenseStatusProps = {
isValidating: boolean;
isValid: boolean;
invalidMessage: string;
};
const LicenseStatus = ({ isValidating, isValid, invalidMessage }: LicenseStatusProps) => {
const { t } = useTranslation();
if (isValidating) {
return (
<Callout role='status' icon='reload' type='info' title={`${t('Validating_license')}...`}>
<Skeleton width='x320' />
</Callout>
);
}
if (isValid) {
return (
<Callout role='status' type='success' title={t('Valid_license')}>
{t('This_license_is_valid_and_ready_to_apply')}
</Callout>
);
}
return (
<Callout role='alert' type='danger' title={t('Invalid_license')}>
{invalidMessage}
</Callout>
);
};
export default LicenseStatus;

View File

@@ -0,0 +1,162 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import ManageLicenseModal from './ManageLicenseModal';
import * as stories from './ManageLicenseModal.stories';
import createDeferredMockFn from '../../../../../../../../tests/mocks/utils/createDeferredMockFn';
// Long enough to pass isPlausibleLicense (>= 100 chars) so validation actually runs.
const LICENSE = 'a'.repeat(120);
const OTHER_LICENSE = 'b'.repeat(120);
const reasons = (...pairs: [string, string][]): BehaviorWithContext[] =>
pairs.map(([behavior, reason]) => ({ behavior, reason }) as BehaviorWithContext);
// The endpoint is typed `void`; the REST client resolves it as `null`, so mocks must return null.
const validationSuccess = () => null;
const validationFailure = (fails: BehaviorWithContext[]) => () => Promise.reject({ reasons: fails });
// getByLabelText matches by label association regardless of visibility, so it reaches the display:none input.
const fileInput = () => screen.getByLabelText('Upload_license_file');
const enterLicense = async (text: string) => {
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste(text);
};
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: mockAppRoot().build() });
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should show a success status for a valid license', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});
await enterLicense(LICENSE);
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
});
it('should map a validation failure to a specific message', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', validationFailure(reasons(['invalidate_license', 'period'])))
.build(),
});
await enterLicense(LICENSE);
expect(await screen.findByText('License_error_expired')).toBeInTheDocument();
expect(screen.getByText('Invalid_license')).toBeInTheDocument();
});
it('should show a generic message when validation fails for a non-license reason', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', () => Promise.reject(new Error('network down')))
.build(),
});
await enterLicense(LICENSE);
expect(await screen.findByText('License_error_generic')).toBeInTheDocument();
});
it('should show a validating status while the request is in flight', async () => {
const { fn, resolve } = createDeferredMockFn<null>();
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', fn).build(),
});
await enterLicense(LICENSE);
expect(await screen.findByText('Validating_license...')).toBeInTheDocument();
act(() => resolve(null));
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
});
it('should populate the preview and validate an uploaded .txt file', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});
await userEvent.upload(fileInput(), new File([LICENSE], 'license.txt', { type: 'text/plain' }));
expect(await screen.findByText('license.txt')).toBeInTheDocument();
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
});
it('should reject an uploaded non-txt file with an error status', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().build(),
});
// Bypass the input's `accept` filter so the guard in handleFile is what rejects the file.
const user = userEvent.setup({ applyAccept: false });
await user.upload(fileInput(), new File(['nope'], 'license.png', { type: 'image/png' }));
expect(await screen.findByText('Only_txt_license_files_are_supported')).toBeInTheDocument();
});
it('should apply a valid license and close the modal', async () => {
const onCancel = jest.fn();
render(<ManageLicenseModal enterpriseLicense='' onCancel={onCancel} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});
await userEvent.upload(fileInput(), new File([OTHER_LICENSE], 'license.txt', { type: 'text/plain' }));
const applyButton = screen.getByRole('button', { name: 'Apply_license' });
await waitFor(() => expect(applyButton).toBeEnabled());
await userEvent.click(applyButton);
await waitFor(() => expect(onCancel).toHaveBeenCalled());
});
it('should keep the apply button disabled after erasing a valid license', async () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});
await enterLicense(LICENSE);
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
const applyButton = screen.getByRole('button', { name: 'Apply_license' });
await waitFor(() => expect(applyButton).toBeEnabled());
await userEvent.clear(screen.getByRole('textbox'));
await waitFor(() => expect(applyButton).toBeDisabled());
});
it('should ask for confirmation before removing the current license', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});
// The remove action only shows when the entered license is the applied one.
await userEvent.click(await screen.findByRole('button', { name: 'Remove_license' }));
expect(screen.getByRole('heading', { name: 'Remove_license_key' })).toBeInTheDocument();
});

View File

@@ -0,0 +1,75 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import { screen, userEvent, expect } from 'storybook/test';
import ManageLicenseModal from './ManageLicenseModal';
const SAMPLE_LICENSE = 'RCLICENSE-'.padEnd(120, 'x');
const validationSuccess = () => null;
const validationFailure = (reasons: BehaviorWithContext[]) => () => Promise.reject({ reasons });
const enterLicense = async (text: string) => {
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste(text);
};
export default {
component: ManageLicenseModal,
parameters: {
layout: 'fullscreen',
},
args: {
onCancel: action('onCancel'),
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
} satisfies Meta<typeof ManageLicenseModal>;
type Story = StoryObj<typeof ManageLicenseModal>;
export const Default: Story = {
args: {
enterpriseLicense: '',
},
};
export const WithCurrentLicense: Story = {
args: {
enterpriseLicense: SAMPLE_LICENSE,
},
};
export const InvalidLicense: Story = {
args: {
enterpriseLicense: '',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', validationFailure([{ behavior: 'invalidate_license', reason: 'period' }]))
.build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
play: async () => {
await enterLicense(SAMPLE_LICENSE);
await expect(await screen.findByText('Invalid_license')).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,181 @@
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { FieldLabel, Field, FieldRow, TextAreaInput } from '@rocket.chat/fuselage-forms';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { GenericModal, useInvalidateLicense } from '@rocket.chat/ui-client';
import { useSettingSetValue, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LicenseFilePreview from './LicenseFilePreview';
import LicenseStatus from './LicenseStatus';
import { getLicenseInvalidMessage } from './getLicenseInvalidMessage';
import { useLicenseFileInput } from './useLicenseFileInput';
import { isPlausibleLicense, useValidateLicense } from '../../../../hooks/useValidateLicense';
type ManageLicenseModalProps = {
enterpriseLicense: string;
onCancel: () => void;
};
const ManageLicenseModal = ({ enterpriseLicense, onCancel }: ManageLicenseModalProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const dispatchToastMessage = useToastMessageDispatch();
const setEnterpriseLicense = useSettingSetValue('Enterprise_License');
const invalidateLicense = useInvalidateLicense();
const [isConfirmingRemoval, setIsConfirmingRemoval] = useState(false);
const {
selectedFile,
fileError,
license,
isDragOver,
handleRemoveFile,
handleFile,
handleDrop,
handleDragLeave,
handleDragOver,
handleTextChange,
} = useLicenseFileInput(enterpriseLicense);
const trimmedLicense = license.trim();
const debouncedLicense = useDebouncedValue(trimmedLicense, 500);
const isEmpty = trimmedLicense === '';
const isPlausible = isPlausibleLicense(trimmedLicense);
const isCurrentLicense = !isEmpty && trimmedLicense === enterpriseLicense.trim();
const { data: validation, isPending, isError } = useValidateLicense(debouncedLicense, !isCurrentLicense);
const isValidating = isPlausible && !isCurrentLicense && (trimmedLicense !== debouncedLicense || isPending);
const isLicenseValid = isPlausible && !fileError && !isError && validation?.valid === true;
const invalidMessage = (() => {
if (fileError) {
return fileError;
}
if (isError) {
return t('License_error_generic');
}
return t(getLicenseInvalidMessage(validation?.reasons ?? []));
})();
const showStatus = !isCurrentLicense && (isPlausible || Boolean(fileError));
const handleApply = async () => {
try {
await setEnterpriseLicense(license);
invalidateLicense(100);
onCancel();
dispatchToastMessage({ type: 'success', message: t('Cloud_License_applied_successfully') });
} catch (err) {
dispatchToastMessage({ type: 'error', message: err });
}
};
const handleRemove = async () => {
try {
await setEnterpriseLicense('');
invalidateLicense(100);
onCancel();
dispatchToastMessage({ type: 'success', message: t('License_removed_successfully') });
} catch (err) {
dispatchToastMessage({ type: 'error', message: err });
}
};
if (isConfirmingRemoval) {
return (
<GenericModal
variant='secondary-danger'
title={t('Remove_license_key')}
confirmText={t('Apply_community_license')}
onConfirm={handleRemove}
onCancel={() => setIsConfirmingRemoval(false)}
>
<Box display='flex' flexDirection='column' gap={16}>
<Box>{t('Remove_license_confirmation')}</Box>
<Box>{t('Are_you_sure_you_want_to_continue_question')}</Box>
<Box fontScale='c1' color='secondary-info'>
{t('Remove_license_disclaimer')}
</Box>
</Box>
</GenericModal>
);
}
return (
<GenericModal
icon={null}
variant='warning'
title={t('Manage_license')}
confirmText={t('Apply_license')}
confirmDisabled={!isLicenseValid || isCurrentLicense || isValidating}
onConfirm={handleApply}
onCancel={onCancel}
>
<Box fontScale='p2' marginBlockEnd={8}>
{t('Manage_license_description')}
</Box>
<Box
is='input'
ref={inputRef}
type='file'
accept='.txt,text/plain'
aria-label={t('Upload_license_file')}
display='none'
onChange={(event: ChangeEvent<HTMLInputElement>) => {
void handleFile(event.currentTarget.files?.[0]);
// Reset so selecting the same file again still fires onChange.
event.currentTarget.value = '';
}}
/>
{selectedFile && <LicenseFilePreview selectedFile={selectedFile} handleRemoveFile={handleRemoveFile} />}
<Field>
<FieldLabel>{t('License_key')}</FieldLabel>
<FieldRow>
<TextAreaInput
withRichContent
marginBlockEnd={8}
width='100%'
backgroundColor={isDragOver ? 'tint' : undefined}
borderStyle={isDragOver ? 'dashed' : 'solid'}
borderColor={isDragOver ? 'stroke-highlight' : 'stroke-light'}
style={{ fontFamily: 'monospace' }}
rows={4}
placeholder={t('Drag_and_drop_license_placeholder')}
value={license}
onChange={handleTextChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
/>
</FieldRow>
</Field>
<ButtonGroup>
<Button icon='upload' small onClick={() => inputRef.current?.click()}>
{t('Upload_license_file')}
</Button>
{isCurrentLicense && (
<Button icon='trash' small secondary danger onClick={() => setIsConfirmingRemoval(true)}>
{t('Remove_license')}
</Button>
)}
</ButtonGroup>
{showStatus && (
<Box marginBlockStart={16}>
<LicenseStatus isValidating={isValidating} isValid={isLicenseValid} invalidMessage={invalidMessage} />
</Box>
)}
</GenericModal>
);
};
export default ManageLicenseModal;

View File

@@ -0,0 +1,451 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Manage_license
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-rwaa9d"
>
Manage_license_description
</div>
<input
accept=".txt,text/plain"
aria-label="Upload_license_file"
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
type="file"
/>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_1_-label"
>
License_key
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<textarea
aria-describedby=""
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
cols="1"
id="react-aria-_r_1_-label"
placeholder="Drag_and_drop_license_placeholder"
rows="4"
size="1"
style="font-family: monospace;"
/>
</span>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
>
</i>
Upload_license_file
</span>
</button>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Apply_license
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders InvalidLicense without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_2_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_2_-title"
>
Manage_license
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-rwaa9d"
>
Manage_license_description
</div>
<input
accept=".txt,text/plain"
aria-label="Upload_license_file"
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
type="file"
/>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_3_-label"
>
License_key
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<textarea
aria-describedby=""
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
cols="1"
id="react-aria-_r_3_-label"
placeholder="Drag_and_drop_license_placeholder"
rows="4"
size="1"
style="font-family: monospace;"
/>
</span>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
>
</i>
Upload_license_file
</span>
</button>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Apply_license
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders WithCurrentLicense without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_4_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_4_-title"
>
Manage_license
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-rwaa9d"
>
Manage_license_description
</div>
<input
accept=".txt,text/plain"
aria-label="Upload_license_file"
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
type="file"
/>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_5_-label"
>
License_key
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<textarea
aria-describedby=""
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
cols="1"
id="react-aria-_r_5_-label"
placeholder="Drag_and_drop_license_placeholder"
rows="4"
size="1"
style="font-family: monospace;"
>
RCLICENSE-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</textarea>
</span>
</div>
<div
class="rcx-button-group rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
>
</i>
Upload_license_file
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button--secondary-danger rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-1hdf9ok"
>
</i>
Remove_license
</span>
</button>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Apply_license
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1,36 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { getLicenseInvalidMessage } from './getLicenseInvalidMessage';
const reason = (behavior: string, reason: string) => ({ behavior, reason }) as BehaviorWithContext;
it('should treat an empty reason list as an undecodable license', () => {
expect(getLicenseInvalidMessage([])).toBe('License_error_format_invalid');
});
it('should map an invalidate_license/url reason to a workspace URL mismatch', () => {
expect(getLicenseInvalidMessage([reason('invalidate_license', 'url')])).toBe('License_error_url_mismatch');
});
it('should map an invalidate_license/period reason to an expired license', () => {
expect(getLicenseInvalidMessage([reason('invalidate_license', 'period')])).toBe('License_error_expired');
});
it('should map a prevent_installation/period reason to an out-of-date-range license', () => {
expect(getLicenseInvalidMessage([reason('prevent_installation', 'period')])).toBe('License_error_outside_date_range');
});
it('should map a limit reason from either behavior to exceeded limits', () => {
expect(getLicenseInvalidMessage([reason('invalidate_license', 'limit')])).toBe('License_error_limits_exceeded');
expect(getLicenseInvalidMessage([reason('prevent_installation', 'limit')])).toBe('License_error_limits_exceeded');
});
it('should prioritize a URL mismatch over other reasons', () => {
expect(getLicenseInvalidMessage([reason('invalidate_license', 'period'), reason('invalidate_license', 'url')])).toBe(
'License_error_url_mismatch',
);
});
it('should fall back to a generic message for unrecognized reasons', () => {
expect(getLicenseInvalidMessage([reason('invalidate_license', 'something_new')])).toBe('License_error_generic');
});

View File

@@ -0,0 +1,28 @@
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
export const getLicenseInvalidMessage = (reasons: BehaviorWithContext[]): TranslationKey => {
const hasErrorReason = (behavior: LicenseBehavior, reason: string) => reasons.some((r) => r.behavior === behavior && r.reason === reason);
if (reasons.length === 0) {
return 'License_error_format_invalid';
}
if (hasErrorReason('invalidate_license', 'url')) {
return 'License_error_url_mismatch';
}
if (hasErrorReason('invalidate_license', 'period')) {
return 'License_error_expired';
}
if (hasErrorReason('prevent_installation', 'period')) {
return 'License_error_outside_date_range';
}
if (hasErrorReason('invalidate_license', 'limit') || hasErrorReason('prevent_installation', 'limit')) {
return 'License_error_limits_exceeded';
}
return 'License_error_generic';
};

View File

@@ -0,0 +1 @@
export { default } from './ManageLicenseModal';

View File

@@ -0,0 +1,108 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { DragEvent } from 'react';
import { useLicenseFileInput } from './useLicenseFileInput';
const CURRENT_LICENSE = 'current-license-key';
const txtFile = (content: string, name = 'license.txt') => new File([content], name, { type: 'text/plain' });
const dragEvent = (types: string[], file?: File) =>
({
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
dataTransfer: { types, dropEffect: '', files: file ? [file] : [] },
}) as unknown as DragEvent<HTMLElement>;
const renderInput = (initial = CURRENT_LICENSE) => renderHook(() => useLicenseFileInput(initial), { wrapper: mockAppRoot().build() });
it('should seed the license with the current license', () => {
const { result } = renderInput();
expect(result.current.license).toBe(CURRENT_LICENSE);
expect(result.current.selectedFile).toBeUndefined();
expect(result.current.fileError).toBeUndefined();
});
it('should read a .txt file into the license and expose the selected file', async () => {
const { result } = renderInput('');
await act(async () => {
await result.current.handleFile(txtFile('license-from-file'));
});
expect(result.current.license).toBe('license-from-file');
expect(result.current.selectedFile?.name).toBe('license.txt');
expect(result.current.fileError).toBeUndefined();
});
it('should reject a non-txt file without touching the license', async () => {
const { result } = renderInput(CURRENT_LICENSE);
await act(async () => {
await result.current.handleFile(new File(['nope'], 'license.png', { type: 'image/png' }));
});
expect(result.current.fileError).toBe('Only_txt_license_files_are_supported');
expect(result.current.selectedFile).toBeUndefined();
expect(result.current.license).toBe(CURRENT_LICENSE);
});
it('should clear file state and license when the file is removed', async () => {
const { result } = renderInput('');
await act(async () => {
await result.current.handleFile(txtFile('license-from-file'));
});
act(() => result.current.handleRemoveFile());
expect(result.current.license).toBe('');
expect(result.current.selectedFile).toBeUndefined();
expect(result.current.fileError).toBeUndefined();
});
it('should detach the selected file when the text is edited by hand', async () => {
const { result } = renderInput('');
await act(async () => {
await result.current.handleFile(txtFile('license-from-file'));
});
act(() => {
result.current.handleTextChange({ currentTarget: { value: 'typed-by-hand' } } as any);
});
expect(result.current.license).toBe('typed-by-hand');
expect(result.current.selectedFile).toBeUndefined();
expect(result.current.fileError).toBeUndefined();
});
it('should flag drag-over only for file drags and set the copy drop effect', () => {
const { result } = renderInput();
const fileDrag = dragEvent(['Files']);
act(() => result.current.handleDragOver(fileDrag));
expect(result.current.isDragOver).toBe(true);
expect(fileDrag.preventDefault).toHaveBeenCalled();
expect(fileDrag.dataTransfer.dropEffect).toBe('copy');
});
it('should ignore text drags so the textarea handles them natively', () => {
const { result } = renderInput();
const textDrag = dragEvent(['text/plain']);
act(() => result.current.handleDragOver(textDrag));
expect(result.current.isDragOver).toBe(false);
expect(textDrag.preventDefault).not.toHaveBeenCalled();
});
it('should read the dropped file and clear the drag-over state', async () => {
const { result } = renderInput('');
act(() => result.current.handleDragOver(dragEvent(['Files'])));
act(() => result.current.handleDrop(dragEvent(['Files'], txtFile('dropped-license'))));
expect(result.current.isDragOver).toBe(false);
await waitFor(() => expect(result.current.license).toBe('dropped-license'));
});

View File

@@ -0,0 +1,106 @@
import type { ChangeEvent, DragEvent } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
const isTxtFile = (file: File): boolean => file.type === 'text/plain' || file.name.toLowerCase().endsWith('.txt');
const readFileAsText = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === 'string') {
resolve(result);
return;
}
reject(new Error('Failed to read file'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
reader.readAsText(file);
});
const isFileDrag = (event: DragEvent<HTMLElement>) => event.dataTransfer.types.includes('Files');
export const useLicenseFileInput = (enterpriseLicense: string) => {
const { t } = useTranslation();
const [isDragOver, setIsDragOver] = useState(false);
const [selectedFile, setSelectedFile] = useState<File>();
const [fileError, setFileError] = useState<string>();
const [license, setLicense] = useState(enterpriseLicense);
const handleFile = async (selected: File | undefined) => {
if (!selected) {
return;
}
if (!isTxtFile(selected)) {
setSelectedFile(undefined);
setFileError(t('Only_txt_license_files_are_supported'));
return;
}
setFileError(undefined);
setSelectedFile(selected);
try {
setLicense(await readFileAsText(selected));
} catch {
setSelectedFile(undefined);
setLicense('');
setFileError(t('Failed_to_read_file'));
}
};
const handleRemoveFile = () => {
setSelectedFile(undefined);
setFileError(undefined);
setLicense('');
};
const handleDrop = (event: DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
void handleFile(event.dataTransfer.files?.[0]);
};
const handleDragLeave = (event: DragEvent<HTMLElement>) => {
if (!isDragOver) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
};
const handleDragOver = (event: DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
setIsDragOver(true);
};
const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setSelectedFile(undefined);
setFileError(undefined);
setLicense(event.currentTarget.value);
};
return {
selectedFile,
fileError,
license,
isDragOver,
handleFile,
handleRemoveFile,
handleDrop,
handleDragLeave,
handleDragOver,
handleTextChange,
};
};

View File

@@ -0,0 +1,26 @@
import { Box, Card, CardBody, Icon } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import PlanCardHeader from './PlanCardHeader';
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
const PlanCardCommunity = () => {
const { t } = useTranslation();
return (
<Card height='full'>
<PlanCardHeader name={t('Community')} />
<CardBody flexDirection='column'>
<Box display='flex' flexDirection='row' alignItems='center'>
<Icon name='card' size={24} marginInlineEnd={12} /> {t('free_per_month_user')}
</Box>
<Box display='flex' flexDirection='row' alignItems='center'>
<Icon name='cloud-plus' size={24} marginInlineEnd={12} /> {t('Self_managed_hosting')}
</Box>
<PlanCardLicenseDetails />
</CardBody>
</Card>
);
};
export default PlanCardCommunity;

View File

@@ -0,0 +1,14 @@
import { CardTitle, Icon, Palette, CardHeader } from '@rocket.chat/fuselage';
export type PlanCardHeaderProps = { name: string };
const PlanCardHeader = ({ name }: PlanCardHeaderProps) => {
return (
<CardHeader>
<Icon name='rocketchat' color={Palette.badge['badge-background-level-4'].toString()} size='x28' marginInlineEnd={4} />
<CardTitle variant='h3'>{name}</CardTitle>
</CardHeader>
);
};
export default PlanCardHeader;

View File

@@ -0,0 +1,66 @@
import { IconButton, Divider, Box } from '@rocket.chat/fuselage';
import { ActionLink } from '@rocket.chat/layout';
import { usePermission, useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import ManageLicenseModal from './ManageLicenseModal';
import useClipboardWithToast from '../../../../../../hooks/useClipboardWithToast';
import { useServerInfo } from '../../../../../../hooks/useWorkspaceInfo';
const PlanCardLicenseDetails = () => {
const { t } = useTranslation();
const setModal = useSetModal();
const hasPermission = usePermission('edit-privileged-setting');
const siteURL = useSetting('Site_Url', '');
const enterpriseLicense = useSetting('Enterprise_License', '');
const { data: serverInfo } = useServerInfo();
const hashedSiteURL = serverInfo?.hashedWorkspaceUrl ?? '';
const { copy: copySiteURL, hasCopied: hasCopiedSiteURL } = useClipboardWithToast(siteURL);
const { copy: copyHashed, hasCopied: hasCopiedHashed } = useClipboardWithToast(hashedSiteURL);
const handleOpenModal = useCallback(
() => setModal(<ManageLicenseModal enterpriseLicense={enterpriseLicense} onCancel={() => setModal(null)} />),
[enterpriseLicense, setModal],
);
if (!hasPermission) {
return null;
}
return (
<>
<Divider />
<Box>
<Box display='flex'>
<Box marginInlineEnd={4}>{t('Site_Url')}</Box>
{hasCopiedSiteURL ? (
<IconButton success icon='check' mini />
) : (
<IconButton title={t('Copy')} icon='clipboard' mini onClick={() => !hasCopiedSiteURL && copySiteURL()} />
)}
</Box>
<Box withTruncatedText fontScale='p2'>
{siteURL}
</Box>
</Box>
<Box>
<Box display='flex'>
<Box marginInlineEnd={4}>{t('Hashed_Site_Url')}</Box>
{hasCopiedHashed ? (
<IconButton success icon='check' mini />
) : (
<IconButton title={t('Copy')} icon='clipboard' mini onClick={() => !hasCopiedHashed && copyHashed()} />
)}
</Box>
<Box withTruncatedText fontScale='p2'>
{hashedSiteURL}
</Box>
</Box>
<ActionLink onClick={handleOpenModal}>{t('Manage_license')}</ActionLink>
</>
);
};
export default PlanCardLicenseDetails;

View File

@@ -0,0 +1,68 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import { Box, Card, CardBody, Icon, Skeleton } from '@rocket.chat/fuselage';
import { ExternalLink, useLicenseName } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import PlanCardHeader from './PlanCardHeader';
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
import { useFormatDate } from '../../../../../../hooks/useFormatDate';
import { useIsSelfHosted } from '../../../../../../hooks/useIsSelfHosted';
import { CONTACT_SALES_LINK } from '../../../utils/links';
type LicenseLimits = {
activeUsers: { max: number; value?: number };
};
type PlanCardProps = {
licenseInformation: ILicenseV3['information'];
licenseLimits: LicenseLimits;
};
const PlanCardPremium = ({ licenseInformation, licenseLimits }: PlanCardProps) => {
const { t } = useTranslation();
const { isSelfHosted, isLoading } = useIsSelfHosted();
const formatDate = useFormatDate();
const planName = useLicenseName();
const isAutoRenew = licenseInformation.autoRenew;
const { visualExpiration } = licenseInformation;
return (
<Card height='full'>
<PlanCardHeader name={planName.data ?? ''} />
<CardBody flexDirection='column'>
{licenseLimits?.activeUsers.max === Infinity && (
<Box display='flex' alignItems='center'>
<Icon name='lightning' size='x24' marginInlineEnd={12} />
{t('Unlimited_seats')}
</Box>
)}
{visualExpiration && (
<Box display='flex' alignItems='center'>
<Icon name='calendar' size='x24' marginInlineEnd={12} />
<span>
{isAutoRenew ? (
t('Renews_DATE', { date: formatDate(visualExpiration || '') })
) : (
<Trans i18nKey='Contact_sales_renew_date'>
<ExternalLink to={CONTACT_SALES_LINK}>Contact sales</ExternalLink> to check plan renew date.
</Trans>
)}
</span>
</Box>
)}
{!isLoading ? (
<Box display='flex' alignItems='center'>
<Icon name='cloud-plus' size='x24' marginInlineEnd={12} /> {isSelfHosted ? t('Self_managed_hosting') : t('Cloud_hosting')}
</Box>
) : (
<Skeleton />
)}
<PlanCardLicenseDetails />
</CardBody>
</Card>
);
};
export default PlanCardPremium;

View File

@@ -0,0 +1,71 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import { Box, Card, CardBody, CardControls, CardRow, Tag } from '@rocket.chat/fuselage';
import { ExternalLink, useLicenseName } from '@rocket.chat/ui-client';
import { differenceInDays } from 'date-fns';
import { Trans, useTranslation } from 'react-i18next';
import PlanCardHeader from './PlanCardHeader';
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
import { DOWNGRADE_LINK, TRIAL_LINK } from '../../../utils/links';
import UpgradeButton from '../../UpgradeButton';
type PlanCardProps = {
licenseInformation: ILicenseV3['information'];
};
const PlanCardTrial = ({ licenseInformation }: PlanCardProps) => {
const { t } = useTranslation();
const planName = useLicenseName();
const isSalesAssisted = licenseInformation.grantedBy?.method !== 'self-service' || true;
const { visualExpiration } = licenseInformation;
return (
<Card height='full'>
<PlanCardHeader name={planName.data ?? ''} />
<CardBody flexDirection='column'>
{visualExpiration && (
<CardRow>
<Box fontScale='p2b' marginInlineEnd={8}>
{t('Trial_active')}
</Box>
<Tag>{t('n_days_left', { n: differenceInDays(new Date(visualExpiration), new Date()) })}</Tag>
</CardRow>
)}
<CardRow>
<span>
{isSalesAssisted ? (
<Trans i18nKey='Contact_sales_trial'>
Contact sales to finish your purchase and avoid
<ExternalLink to={DOWNGRADE_LINK}>downgrade consequences.</ExternalLink>
</Trans>
) : (
<Trans i18nKey='Finish_your_purchase_trial'>
Finish your purchase to avoid <ExternalLink to={DOWNGRADE_LINK}>downgrade consequences.</ExternalLink>
</Trans>
)}
</span>
</CardRow>
<CardRow>
<Trans i18nKey='Why_has_a_trial_been_applied_to_this_workspace'>
<ExternalLink to={TRIAL_LINK}>Why has a trial been applied to this workspace?</ExternalLink>
</Trans>
</CardRow>
<PlanCardLicenseDetails />
</CardBody>
<CardControls>
<UpgradeButton
target='plan_card_trial'
action={isSalesAssisted ? 'finish_purchase' : 'contact_sales'}
primary
marginBlockStart='auto'
width='full'
>
{isSalesAssisted ? t('Finish_purchase') : t('Contact_sales')}
</UpgradeButton>
</CardControls>
</Card>
);
};
export default PlanCardTrial;

View File

@@ -0,0 +1,52 @@
import { Palette } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import type { CardProps } from '../FeatureUsageCard';
import FeatureUsageCard from '../FeatureUsageCard';
import FeatureUsageCardBody from '../FeatureUsageCardBody';
import UpgradeButton from '../UpgradeButton';
import UsagePieGraph from '../UsagePieGraph';
export type SeatsCardProps = {
value: number;
max: number;
hideManageSubscription?: boolean;
};
const SeatsCard = ({ value, max, hideManageSubscription }: SeatsCardProps) => {
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'),
...(!hideManageSubscription &&
nearLimit && {
upgradeButton: (
<UpgradeButton target='seats-card' action='buy_more' small>
{t('Buy_more')}
</UpgradeButton>
),
}),
};
const seatsLeft = pieGraph.total - pieGraph.used;
const color = pieGraph.used / pieGraph.total >= 0.8 ? Palette.statusColor['status-font-on-danger'].toString() : undefined;
const message = seatsLeft > 0 ? t('Seats_Available', { seatsLeft }) : undefined;
return (
<FeatureUsageCard card={card}>
<FeatureUsageCardBody>
<UsagePieGraph label={message} used={pieGraph.used} total={pieGraph.total} color={color} />
</FeatureUsageCardBody>
</FeatureUsageCard>
);
};
export default SeatsCard;