Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/license-validate-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/license': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds a new `licenses.validate` REST endpoint that validates a Rocket.Chat license (V2 or V3 JWT) against the current workspace without applying it, so a license can be previewed before it is applied from the UI. A valid license responds with success; an invalid one responds with the validation behaviors that rejected it.
6 changes: 6 additions & 0 deletions .changeset/silver-cars-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds a manage license flow to the subscription admin page, allowing license verification before applying it and an option to remove the license. Note: From this point license management should be made in subscription page instead of the Enterprise settings page.
Comment thread
dougfabris marked this conversation as resolved.
52 changes: 30 additions & 22 deletions apps/meteor/client/hooks/useWorkspaceInfo.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,43 @@
import type { IStats, IWorkspaceInfo, Serialized } from '@rocket.chat/core-typings';
import type { IInstance } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';

const useServerInfoQueryOptions = () => {
const getServerInfo = useEndpoint('GET', '/info');

return {
queryKey: ['info', 'serverInfo'],
queryFn: async () => {
const data = await getServerInfo();

if (!('minimumClientVersions' in data)) {
throw new Error('Invalid server info');
}
if (!('info' in data)) {
throw new Error('Invalid server info');
}
if (!('version' in data)) {
throw new Error('Invalid server info');
}

return data as IWorkspaceInfo;
},
staleTime: Infinity,
placeholderData: keepPreviousData,
} as const;
};

export const useServerInfo = () => useQuery(useServerInfoQueryOptions());

export const useWorkspaceInfo = ({ refreshStatistics }: { refreshStatistics?: boolean } = {}) => {
const getStatistics = useEndpoint('GET', '/v1/statistics');
const getInstances = useEndpoint('GET', '/v1/instances.get');
const getServerInfo = useEndpoint('GET', '/info');
const serverInfoQueryOptions = useServerInfoQueryOptions();

return useQueries({
queries: [
{
queryKey: ['info', 'serverInfo'],
queryFn: async () => {
const data = await getServerInfo();

if (!('minimumClientVersions' in data)) {
throw new Error('Invalid server info');
}
if (!('info' in data)) {
throw new Error('Invalid server info');
}
if (!('version' in data)) {
throw new Error('Invalid server info');
}

return data as IWorkspaceInfo;
},
staleTime: Infinity,
placeholderData: keepPreviousData,
},
serverInfoQueryOptions,
{
queryKey: ['info', 'instances'],
queryFn: () => getInstances(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSettingStructure } from '@rocket.chat/ui-contexts';

import SettingsGroupPageSkeleton from '../SettingsGroupPage/SettingsGroupPageSkeleton';
import BaseGroupPage from '../groups/BaseGroupPage';
import EnterpriseGroupPage from '../groups/EnterpriseGroupPage';
import LDAPGroupPage from '../groups/LDAPGroupPage';
import OAuthGroupPage from '../groups/OAuthGroupPage';

Expand Down Expand Up @@ -30,6 +31,10 @@ const SettingsGroupSelector = ({ groupId, onClickBack }: SettingsGroupSelectorPr
return <BaseGroupPage {...group} onClickBack={onClickBack} hasReset={false} />;
}

if (groupId === 'Enterprise') {
return <EnterpriseGroupPage {...group} onClickBack={onClickBack} />;
}

return <BaseGroupPage {...group} onClickBack={onClickBack} />;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Box } from '@rocket.chat/fuselage';
import { PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { MouseEvent } from 'react';
import { Trans } from 'react-i18next';

import SettingsGroupPage from '../SettingsGroupPage';

type EnterpriseGroupPageProps = {
_id: string;
i18nLabel: string;
currentTab?: string;
hasReset?: boolean;
onClickBack?: () => void;
};

const useRedirectToRouteLink = (onClick: (event: MouseEvent<HTMLAnchorElement>) => void) => {
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
onClick(event);
},
[onClick],
);

return { href: '#', onClick: handleClick };
};

const EnterpriseGroupPage = ({ _id, i18nLabel, onClickBack, ...props }: EnterpriseGroupPageProps) => {
const { navigate } = useRouter();
const redirectProps = useRedirectToRouteLink(() => navigate('/admin/subscription'));

return (
<SettingsGroupPage isCustom _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} {...props}>
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
<Trans
i18nKey='Workspace_license_is_now_managed_from_the_subscription_page'
components={{ a: <Box is='a' fontScale='p2' color='info' {...redirectProps} /> }}
/>
</Box>
</PageScrollableContentWithShadow>
</SettingsGroupPage>
);
};

export default EnterpriseGroupPage;
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ 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 { useCancelSubscriptionModal } from './hooks/useCancelSubscriptionModal';
import { useWorkspaceSync } from './hooks/useWorkspaceSync';
Expand Down Expand Up @@ -143,8 +142,7 @@ const SubscriptionPage = () => {
<Box marginBlock='none' marginInline='auto' width='full' color='default'>
<Grid m={0}>
<GridItem lg={4} xs={4} p={8} minHeight={260}>
{license && <PlanCard licenseInformation={license.information} licenseLimits={{ activeUsers: seatsLimit }} />}
{!license && <PlanCardCommunity />}
<PlanCard license={license} licenseLimits={{ activeUsers: seatsLimit }} />
</GridItem>

<GridItem lg={8} xs={4} p={8} minHeight={260}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const FeaturesCard = ({ activeModules, isEnterprise }: FeaturesCardProps) => {
const isSmall = useMediaQuery('(min-width: 1180px)');

return (
<Card>
<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'}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';

import PlanCardCommunity from './PlanCard/PlanCardCommunity';
import PlanCardPremium from './PlanCard/PlanCardPremium';
import PlanCardTrial from './PlanCard/PlanCardTrial';

Expand All @@ -8,17 +9,21 @@ type LicenseLimits = {
};

type PlanCardProps = {
licenseInformation: ILicenseV3['information'];
license?: ILicenseV3;
licenseLimits: LicenseLimits;
};

const PlanCard = ({ licenseInformation, licenseLimits }: PlanCardProps) => {
const isTrial = licenseInformation.trial;
const PlanCard = ({ license, licenseLimits }: PlanCardProps) => {
const isTrial = license?.information.trial;

if (!license) {
return <PlanCardCommunity />;
}

return isTrial ? (
<PlanCardTrial licenseInformation={licenseInformation} />
<PlanCardTrial licenseInformation={license.information} />
) : (
<PlanCardPremium licenseInformation={licenseInformation} licenseLimits={licenseLimits} />
<PlanCardPremium licenseInformation={license.information} licenseLimits={licenseLimits} />
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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} mbe={8} borderRadius={4} borderWidth={1} borderColor='extra-light'>
<FilePreviewIcon format={getFileExtension(selectedFile.name)} />
<Box flexGrow={1} withTruncatedText mis={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;
Original file line number Diff line number Diff line change
@@ -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 icon='reload' type='info' title={`${t('Validating_license')}...`}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move the ellipsis into the translation string.

Concatenating '...' after t('Validating_license') bypasses i18n control over punctuation placement/glyph for other locales.

🌐 Proposed fix
-			<Callout icon='reload' type='info' title={`${t('Validating_license')}...`}>
+			<Callout icon='reload' type='info' title={t('Validating_license_ellipsis')}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/meteor/client/views/admin/subscription/components/cards/PlanCard/ManageLicenseModal/LicenseStatus.tsx`
at line 15, The Callout title in LicenseStatus adds an ellipsis by concatenating
"..." after t('Validating_license'), which bypasses locale control. Update the
LicenseStatus component to move the ellipsis into the translated text itself,
and keep the Callout title bound to the translation result so punctuation can be
handled per locale.

<Skeleton width='x320' />
</Callout>
);
}

if (isValid) {
return (
<Callout type='success' title={t('Valid_license')}>
{t('This_license_is_valid_and_ready_to_apply')}
</Callout>
);
}

return (
<Callout type='danger' title={t('Invalid_license')}>
{invalidMessage}
</Callout>
);
};

export default LicenseStatus;
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import ManageLicenseModal from './ManageLicenseModal';
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');

it('should render the title, description and a disabled apply button', () => {
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, { wrapper: mockAppRoot().build() });

expect(screen.getByRole('heading', { name: 'Manage_license' })).toBeInTheDocument();
expect(screen.getByText('Manage_license_description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply_license' })).toBeDisabled();
});

it('should show a success status for a valid license', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
});

expect(await screen.findByText('Valid_license')).toBeInTheDocument();
});

it('should map a validation failure to a specific message', async () => {
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', validationFailure(reasons(['invalidate_license', 'period'])))
.build(),
});

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={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/licenses.validate', () => Promise.reject(new Error('network down')))
.build(),
});

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={LICENSE} onCancel={jest.fn()} />, {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', fn).build(),
});

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 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();
});
Loading
Loading