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,57 @@
import type { IStats } from '@rocket.chat/core-typings';
import type { Meta } from '@storybook/react';
import DeploymentCard from './DeploymentCard';
export default {
component: DeploymentCard,
parameters: {
layout: 'centered',
},
args: {
statistics: {
uniqueId: '',
version: '1.0.0',
process: {
nodeVersion: '',
pid: 0,
},
migration: {
version: 272,
lockedAt: '',
},
msEnabled: false,
oplogEnabled: false,
mongoVersion: '',
mongoStorageEngine: '',
} as IStats,
instances: [],
serverInfo: {
info: {
commit: {},
marketplaceApiVersion: '',
build: {
date: '',
arch: '',
platform: '',
cpus: 0,
freeMemory: 0,
totalMemory: 0,
nodeVersion: '',
osRelease: '',
},
version: '',
},
cloudWorkspaceId: '',
minimumClientVersions: {
desktop: '',
mobile: '',
},
version: '',
},
},
} satisfies Meta<typeof DeploymentCard>;
export const Example = {
name: 'DeploymentCard',
};

View File

@@ -0,0 +1,112 @@
import type { IWorkspaceInfo, IStats } from '@rocket.chat/core-typings';
import { Button, Card, CardBody, CardControls, Margins } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { IInstance } from '@rocket.chat/rest-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import WorkspaceCardSection from '../components/WorkspaceCardSection';
import InstancesModal from './components/InstancesModal';
import WorkspaceCardSectionTitle from '../components/WorkspaceCardSectionTitle';
export type DeploymentCardProps = {
serverInfo: IWorkspaceInfo;
instances: IInstance[];
statistics: IStats;
};
const DeploymentCard = ({
serverInfo: { info, cloudWorkspaceId, workspaceUrl, hashedWorkspaceUrl },
statistics,
instances,
}: DeploymentCardProps) => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const setModal = useSetModal();
const { commit = {}, marketplaceApiVersion: appsEngineVersion } = info || {};
const handleInstancesModal = useStableCallback(() => {
setModal(<InstancesModal instances={instances} onClose={(): void => setModal()} />);
});
return (
<Card height='full'>
<CardBody flexDirection='column'>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Deployment')} variant='h4' />
</WorkspaceCardSection>
<Margins blockEnd={8}>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Version')} />
{statistics.version}
</WorkspaceCardSection>
{workspaceUrl && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Site_Url')} />
{workspaceUrl}
</WorkspaceCardSection>
)}
{hashedWorkspaceUrl && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Hashed_Site_Url')} />
<span style={{ lineBreak: 'anywhere' }}>{hashedWorkspaceUrl}</span>
</WorkspaceCardSection>
)}
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Deployment_ID')} />
{statistics.uniqueId}
</WorkspaceCardSection>
{cloudWorkspaceId && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Cloud_Workspace_Id')} />
{cloudWorkspaceId}
</WorkspaceCardSection>
)}
{appsEngineVersion && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Apps_Engine_Version')} />
{appsEngineVersion}
</WorkspaceCardSection>
)}
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Node_version')} />
{statistics.process.nodeVersion}
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('DB_Migration')} />
{`${statistics.migration.version} (${formatDateAndTime(statistics.migration.lockedAt)})`}
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('MongoDB')} />
{`${statistics.mongoVersion} / ${statistics.mongoStorageEngine}`}
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Commit_details')} />
{t('github_HEAD')}: ({commit.hash ? commit.hash.slice(0, 9) : ''}) <br />
{t('Branch')}: {commit.branch} <br />
{commit.subject}
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('PID')} />
{statistics.process.pid}
</WorkspaceCardSection>
</Margins>
</CardBody>
{!!instances.length && (
<CardControls>
<Button medium onClick={handleInstancesModal}>
{t('Instances')}
</Button>
</CardControls>
)}
</Card>
);
};
export default memo(DeploymentCard);

View File

@@ -0,0 +1,24 @@
import type { StoryObj, Meta } from '@storybook/react';
import DescriptionList from './DescriptionList';
import DescriptionListEntry from './DescriptionListEntry';
export default {
component: DescriptionList,
parameters: {
layout: 'centered',
},
decorators: [(fn) => <div>{fn()}</div>],
} satisfies Meta<typeof DescriptionList>;
export const Default: StoryObj<typeof DescriptionList> = {
render: (args) => (
<DescriptionList {...args}>
<DescriptionListEntry label='Key'>Value</DescriptionListEntry>
<DescriptionListEntry label='Key'>Value</DescriptionListEntry>
<DescriptionListEntry label='Key'>Value</DescriptionListEntry>
</DescriptionList>
),
name: 'DescriptionList',
};

View File

@@ -0,0 +1,23 @@
import { Box, Table, TableBody } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { memo } from 'react';
export type DescriptionListProps = {
children: ReactNode;
title?: string;
};
const DescriptionList = ({ children, title }: DescriptionListProps) => (
<>
{title && (
<Box display='flex' justifyContent='flex-end' width='30%' paddingInline={8}>
{title}
</Box>
)}
<Table striped marginBlockEnd={32} width='full'>
<TableBody>{children}</TableBody>
</Table>
</>
);
export default memo(DescriptionList);

View File

@@ -0,0 +1,21 @@
import { TableRow, TableCell } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { memo } from 'react';
export type DescriptionListEntryProps = {
label: ReactNode;
children: ReactNode;
};
const DescriptionListEntry = ({ children, label }: DescriptionListEntryProps) => (
<TableRow>
<TableCell wordBreak='break-word' is='th' scope='col' align='end' color='hint' backgroundColor='surface' fontScale='p2m'>
{label}
</TableCell>
<TableCell align='start' color='default'>
{children}
</TableCell>
</TableRow>
);
export default memo(DescriptionListEntry);

View File

@@ -0,0 +1,52 @@
import type { Meta } from '@storybook/react';
import InstancesModal from './InstancesModal';
export default {
component: InstancesModal,
parameters: {
layout: 'fullscreen',
},
argTypes: {
onClose: { action: 'onClose' },
},
} satisfies Meta<typeof InstancesModal>;
export const Default = {
name: 'InstancesModal',
args: {
instances: [
{
address: 'http://localhost:3000',
broadcastAuth: false,
currentStatus: {
connected: true,
},
instanceRecord: {
_createdAt: new Date(),
_id: 'instance-id',
name: 'instance-name',
pid: 123,
extraInformation: {
host: '127.0.0.1',
nodeVersion: 'v14.18.2',
port: '',
tcpPort: 123,
os: {
type: 'macOSX',
platform: '',
arch: 'x64',
release: '236',
uptime: 10,
loadavg: [123, 123],
totalmem: 123,
freemem: 123,
cpus: 8,
},
},
},
},
],
},
};

View File

@@ -0,0 +1,81 @@
import { Accordion, AccordionItem } from '@rocket.chat/fuselage';
import type { IInstance } from '@rocket.chat/rest-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import DescriptionList from './DescriptionList';
import DescriptionListEntry from './DescriptionListEntry';
import { useFormatDateAndTime } from '../../../../../../hooks/useFormatDateAndTime';
export type InstancesModalProps = {
instances: IInstance[];
onClose: () => void;
};
const InstancesModal = ({ instances = [], onClose }: InstancesModalProps) => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
return (
<GenericModal onConfirm={onClose} confirmText={t('Close')} icon={null} title={t('Instances')} onClose={onClose}>
<Accordion>
{instances.map(({ address, broadcastAuth, currentStatus, instanceRecord }) => (
<AccordionItem defaultExpanded title={address} key={address}>
<DescriptionList>
<DescriptionListEntry label={t('Address')}>{address}</DescriptionListEntry>
<DescriptionListEntry label={t('Auth')}>{broadcastAuth ? 'true' : 'false'}</DescriptionListEntry>
<DescriptionListEntry
label={
<>
{t('Current_Status')} &gt; {t('Connected')}
</>
}
>
{currentStatus.connected ? 'true' : 'false'}
</DescriptionListEntry>
<DescriptionListEntry
label={
<>
{t('Current_Status')} &gt; {t('Local')}
</>
}
>
{currentStatus.local ? 'true' : 'false'}
</DescriptionListEntry>
<DescriptionListEntry
label={
<>
{t('Current_Status')} &gt; {t('Last_Heartbeat_Time')}
</>
}
>
{currentStatus.lastHeartbeatTime}
</DescriptionListEntry>
<DescriptionListEntry label={<>{t('Instance_Record')} &gt; ID</>}>{instanceRecord?._id}</DescriptionListEntry>
<DescriptionListEntry
label={
<>
{t('Instance_Record')} &gt; {t('PID')}
</>
}
>
{instanceRecord?.pid}
</DescriptionListEntry>
<DescriptionListEntry
label={
<>
{t('Instance_Record')} &gt; {t('Created_at')}
</>
}
>
{formatDateAndTime(instanceRecord?._createdAt)}
</DescriptionListEntry>
</DescriptionList>
</AccordionItem>
))}
</Accordion>
</GenericModal>
);
};
export default InstancesModal;

View File

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

View File

@@ -0,0 +1,27 @@
import type { IStats } from '@rocket.chat/core-typings';
import type { Meta } from '@storybook/react';
import MessagesRoomsCard from './MessagesRoomsCard';
export default {
component: MessagesRoomsCard,
parameters: {
layout: 'centered',
},
args: {
statistics: {
totalChannels: 12,
totalPrivateGroups: 23,
totalDirect: 21,
totalDiscussions: 32,
totalLivechat: 31,
totalMessages: 321,
totalDirectMessages: 23,
totalDiscussionsMessages: 32,
totalLivechatMessages: 31,
} as IStats,
},
} satisfies Meta<typeof MessagesRoomsCard>;
export const Example = {};
export const Vertical = {};

View File

@@ -0,0 +1,51 @@
import type { IStats } from '@rocket.chat/core-typings';
import { Card } from '@rocket.chat/fuselage';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import WorkspaceCardSection from '../components/WorkspaceCardSection';
import WorkspaceCardSectionTitle from '../components/WorkspaceCardSectionTitle';
import WorkspaceCardTextSeparator from '../components/WorkspaceCardTextSeparator';
export type MessagesRoomsCardProps = {
statistics: IStats;
};
const MessagesRoomsCard = ({ statistics }: MessagesRoomsCardProps) => {
const { t } = useTranslation();
return (
<Card height='full'>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Total_rooms')} variant='h4' />
<WorkspaceCardTextSeparator label={t('Channels')} icon='hash' value={statistics.totalChannels} />
<WorkspaceCardTextSeparator label={t('Private_Groups')} icon='lock' value={statistics.totalPrivateGroups} />
<WorkspaceCardTextSeparator label={t('Direct_Messages')} icon='balloon' value={statistics.totalDirect} />
<WorkspaceCardTextSeparator label={t('Discussions')} icon='discussion' value={statistics.totalDiscussions} />
<WorkspaceCardTextSeparator label={t('Omnichannel')} icon='headset' value={statistics.totalLivechat} />
<WorkspaceCardTextSeparator label={t('Total')} value={statistics.totalRooms} />
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Messages')} variant='h4' />
<WorkspaceCardTextSeparator label={t('Stats_Total_Messages_Channel')} icon='hash' value={statistics.totalChannelMessages} />
<WorkspaceCardTextSeparator
label={t('Stats_Total_Messages_PrivateGroup')}
icon='lock'
value={statistics.totalPrivateGroupMessages}
/>
<WorkspaceCardTextSeparator label={t('Stats_Total_Messages_Direct')} icon='balloon' value={statistics.totalDirectMessages} />
<WorkspaceCardTextSeparator
label={t('Stats_Total_Messages_Discussions')}
icon='discussion'
value={statistics.totalDiscussionsMessages}
/>
<WorkspaceCardTextSeparator label={t('Stats_Total_Messages_Livechat')} icon='headset' value={statistics.totalLivechatMessages} />
<WorkspaceCardTextSeparator label={t('Total')} value={statistics.totalMessages} />
</WorkspaceCardSection>
</Card>
);
};
export default memo(MessagesRoomsCard);

View File

@@ -0,0 +1,71 @@
import type { IStats } from '@rocket.chat/core-typings';
import { Button, Card, CardBody, CardControls, Margins } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRouter } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatMemorySize } from '../../../../hooks/useFormatMemorySize';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
import WorkspaceCardSection from '../components/WorkspaceCardSection';
import WorkspaceCardSectionTitle from '../components/WorkspaceCardSectionTitle';
import WorkspaceCardTextSeparator from '../components/WorkspaceCardTextSeparator';
export type UsersUploadsCardProps = {
statistics: IStats;
};
const UsersUploadsCard = ({ statistics }: UsersUploadsCardProps) => {
const { t } = useTranslation();
const formatMemorySize = useFormatMemorySize();
const router = useRouter();
const handleEngagement = useStableCallback(() => {
router.navigate('/admin/engagement');
});
const { data: canViewEngagement = false } = useHasLicenseModule('engagement-dashboard');
return (
<Card height='full'>
<CardBody flexDirection='column'>
<Margins blockEnd={24}>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Users')} variant='h4' />
<WorkspaceCardTextSeparator label={t('Online')} status='online' value={statistics.onlineUsers} />
<WorkspaceCardTextSeparator label={t('Busy')} status='busy' value={statistics.busyUsers} />
<WorkspaceCardTextSeparator label={t('Away')} status='away' value={statistics.awayUsers} />
<WorkspaceCardTextSeparator label={t('Offline')} status='offline' value={statistics.offlineUsers} />
<WorkspaceCardTextSeparator label={t('Total')} value={statistics.totalUsers} />
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Types')} variant='h4' />
<WorkspaceCardTextSeparator label={t('Users_Connected')} value={statistics.totalConnectedUsers} />
<WorkspaceCardTextSeparator label={t('Stats_Active_Users')} value={statistics.activeUsers} />
<WorkspaceCardTextSeparator label={t('Stats_Active_Guests')} value={statistics.activeGuests} />
<WorkspaceCardTextSeparator label={t('Stats_Non_Active_Users')} value={statistics.nonActiveUsers} />
<WorkspaceCardTextSeparator label={t('Stats_App_Users')} value={statistics.appUsers} />
</WorkspaceCardSection>
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Uploads')} variant='h4' />
<WorkspaceCardTextSeparator label={t('Stats_Total_Uploads')} value={statistics.uploadsTotal} />
<WorkspaceCardTextSeparator label={t('Stats_Total_Uploads_Size')} value={formatMemorySize(statistics.uploadsTotalSize)} />
</WorkspaceCardSection>
</Margins>
</CardBody>
<CardControls>
<Button disabled={!canViewEngagement} medium onClick={handleEngagement}>
{t('See_on_Engagement_Dashboard')}
</Button>
</CardControls>
</Card>
);
};
export default memo(UsersUploadsCard);

View File

@@ -0,0 +1,225 @@
import type { IWorkspaceInfo } from '@rocket.chat/core-typings';
import { Box, Card, CardBody, CardCol, CardControls, CardHeader, CardTitle, Icon } from '@rocket.chat/fuselage';
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { SupportedVersions } from '@rocket.chat/server-cloud-communication';
import { ExternalLink, useLicense, useLicenseName } from '@rocket.chat/ui-client';
import type { LocationPathname } from '@rocket.chat/ui-contexts';
import { useSetModal, useMediaUrl } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import VersionCardActionButton from './components/VersionCardActionButton';
import type { VersionActionItem } from './components/VersionCardActionItem';
import VersionCardActionItem from './components/VersionCardActionItem';
import { VersionCardSkeleton } from './components/VersionCardSkeleton';
import { VersionTag } from './components/VersionTag';
import { getVersionStatus } from './getVersionStatus';
import RegisterWorkspaceModal from './modals/RegisterWorkspaceModal';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import { useRegistrationStatus } from '../../../../hooks/useRegistrationStatus';
import { links } from '../../../../lib/links';
import { isOverLicenseLimits } from '../../../../lib/utils/isOverLicenseLimits';
const SUPPORT_EXTERNAL_LINK = links.go.versionSupport;
const RELEASES_EXTERNAL_LINK = links.go.updateProduct;
export type VersionCardProps = {
serverInfo: IWorkspaceInfo;
};
const VersionCard = ({ serverInfo }: VersionCardProps) => {
const breakpoints = useBreakpoints();
const isExtraLargeOrBigger = breakpoints.includes('xl');
const getUrl = useMediaUrl();
const cardBackground = {
backgroundImage: `url(${getUrl('images/globe.png')})`,
backgroundRepeat: 'no-repeat',
backgroundPosition: isExtraLargeOrBigger ? 'right 20px center' : 'left 450px center',
backgroundSize: 'auto',
};
const setModal = useSetModal();
const { t } = useTranslation();
const formatDate = useFormatDate();
const { data: licenseData, isPending, refetch: refetchLicense } = useLicense({ loadValues: true });
const { isRegistered, canViewRegistrationStatus } = useRegistrationStatus();
const { license, limits } = licenseData || {};
const isAirgapped = license?.information?.offline;
const licenseName = useLicenseName();
const serverVersion = serverInfo.version;
const { versionStatus, versions } = useMemo(() => {
const supportedVersions = serverInfo?.supportedVersions?.signed ? decodeBase64(serverInfo?.supportedVersions?.signed) : undefined;
if (!supportedVersions) {
return {};
}
const versionStatus = getVersionStatus(serverVersion, supportedVersions?.versions);
return {
versionStatus,
versions: supportedVersions?.versions,
};
}, [serverInfo?.supportedVersions?.signed, serverVersion]);
const isOverLimits = limits && isOverLicenseLimits(limits);
const actionButton:
| undefined
| {
path: LocationPathname;
label: ReactNode;
}
| {
action: () => void;
label: ReactNode;
} = useMemo(() => {
if (canViewRegistrationStatus && !isRegistered) {
return {
action: () => {
const handleModalClose = (): void => {
setModal(null);
refetchLicense();
};
setModal(<RegisterWorkspaceModal onClose={handleModalClose} onStatusChange={refetchLicense} />);
},
label: t('RegisterWorkspace_Button'),
};
}
if (versionStatus?.label === 'outdated') {
return {
action: () => {
window.open(RELEASES_EXTERNAL_LINK, '_blank');
},
label: t('Update_version'),
};
}
if (isOverLimits) {
return { path: '/admin/subscription', label: t('Manage_subscription') };
}
}, [canViewRegistrationStatus, isRegistered, versionStatus, isOverLimits, t, setModal, refetchLicense]);
const actionItems = useMemo(() => {
return (
[
isOverLimits
? {
danger: true,
icon: 'warning',
label: t('Plan_limits_reached'),
}
: {
icon: 'check',
label: t('Operating_withing_plan_limits'),
},
(isAirgapped || !versions) && {
icon: 'warning',
label: (
<Trans i18nKey='Check_support_availability'>
Check
<ExternalLink to={SUPPORT_EXTERNAL_LINK}>support</ExternalLink>
availability
</Trans>
),
},
versionStatus?.label !== 'outdated' &&
versionStatus?.expiration && {
icon: 'check',
label: (
<Trans i18nKey='Version_supported_until' values={{ date: formatDate(versionStatus?.expiration) }}>
Version
<ExternalLink to={SUPPORT_EXTERNAL_LINK}>supported</ExternalLink>
until {formatDate(versionStatus?.expiration)}
</Trans>
),
},
versionStatus?.label === 'outdated' && {
danger: true,
icon: 'warning',
label: (
<Trans i18nKey='Version_not_supported'>
Version
<ExternalLink to={SUPPORT_EXTERNAL_LINK}>not supported</ExternalLink>
</Trans>
),
},
canViewRegistrationStatus &&
(isRegistered
? {
icon: 'check',
label: t('Workspace_registered'),
}
: {
danger: true,
icon: 'warning',
label: t('Workspace_not_registered'),
}),
].filter(Boolean) as VersionActionItem[]
).sort((a) => (a.danger ? -1 : 1));
}, [
isOverLimits,
t,
isAirgapped,
versions,
versionStatus?.label,
versionStatus?.expiration,
formatDate,
canViewRegistrationStatus,
isRegistered,
]);
if (isPending && !licenseData) {
return (
<Card style={{ ...cardBackground }}>
<VersionCardSkeleton />
</Card>
);
}
return (
<Card style={{ ...cardBackground }}>
<CardCol>
<CardHeader>
<CardTitle variant='h3'>{t('Version_version', { version: serverVersion })}</CardTitle>
{!isAirgapped && versions && <VersionTag versionStatus={versionStatus?.label} title={versionStatus.version} />}
</CardHeader>
<Box color='secondary-info'>
<Icon name='rocketchat' size={16} /> {licenseName.data}
</Box>
</CardCol>
<CardBody flexDirection='column'>
{actionItems.length > 0 && actionItems.map((item, index) => <VersionCardActionItem key={index} {...item} />)}
</CardBody>
{actionButton && (
<CardControls>
<VersionCardActionButton {...actionButton} />
</CardControls>
)}
</Card>
);
};
export default VersionCard;
const decodeBase64 = (b64: string): SupportedVersions | undefined => {
const [, bodyEncoded] = b64.split('.');
if (!bodyEncoded) {
return;
}
return JSON.parse(atob(bodyEncoded));
};

View File

@@ -0,0 +1,36 @@
import { Button } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { LocationPathname } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { memo } from 'react';
export type VersionCardActionButtonProps =
| {
path: LocationPathname;
label: ReactNode;
}
| {
action: () => void;
label: ReactNode;
};
const VersionCardActionButton = (item: VersionCardActionButtonProps) => {
const router = useRouter();
const handleActionButton = useStableCallback(() => {
if ('action' in item) {
return item.action();
}
router.navigate(item.path);
});
return (
<Button primary onClick={() => handleActionButton()}>
{item.label}
</Button>
);
};
export default memo(VersionCardActionButton);

View File

@@ -0,0 +1,22 @@
import { Box, FramedIcon } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { ReactNode } from 'react';
export type VersionActionItem = {
danger?: boolean;
icon: Keys;
label: ReactNode;
};
export type VersionCardActionItemProps = VersionActionItem;
const VersionCardActionItem = ({ icon, label, danger }: VersionCardActionItemProps) => {
return (
<Box display='flex' alignItems='center' color={danger ? 'danger' : 'secondary-info'} fontScale='p2m'>
<FramedIcon danger={danger} icon={icon} />
<Box marginInlineStart={12}>{label}</Box>
</Box>
);
};
export default VersionCardActionItem;

View File

@@ -0,0 +1,23 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
export const VersionCardSkeleton = () => {
return (
<>
<Skeleton height={28} width={160} />
<Skeleton height={28} width={150} />
<Box display='flex' width={160} height={28} alignItems='center'>
<Skeleton height={38} width={28} marginInlineEnd={12} />
<Skeleton height={20} width={120} />
</Box>
<Box display='flex' width={180} height={28} alignItems='center'>
<Skeleton height={38} width={28} marginInlineEnd={12} />
<Skeleton height={20} width={140} />
</Box>
<Box display='flex' width={160} height={28} alignItems='center'>
<Skeleton height={38} width={28} marginInlineEnd={12} />
<Skeleton height={20} width={120} />
</Box>
<Skeleton height={50} width={160} />
</>
);
};

View File

@@ -0,0 +1,34 @@
import { Tag } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type VersionStatus = 'outdated' | 'latest' | 'available_version' | undefined;
export type VersionTagProps = {
versionStatus: VersionStatus;
title?: string;
};
export const VersionTag = ({ versionStatus, title }: VersionTagProps) => {
const { t } = useTranslation();
if (versionStatus === 'outdated') {
return (
<Tag title={title} variant='danger'>
{t('Outdated')}
</Tag>
);
}
if (versionStatus === 'latest') {
return (
<Tag title={title} variant='primary'>
{t('Latest')}
</Tag>
);
}
return (
<Tag title={title} variant='secondary'>
{t('New_version_available')}
</Tag>
);
};

View File

@@ -0,0 +1,185 @@
import { getVersionStatus } from './getVersionStatus';
describe('if the server version from server and the highest version from cloud are the same', () => {
describe('the expiration date is in the future', () => {
it('should return as latest version', () => {
const status = getVersionStatus('3.0.0', [
{
version: '3.0.0',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('latest');
});
});
describe('the expiration date is in the past', () => {
it('should return as outdated version', () => {
const status = getVersionStatus('3.0.0', [
{
version: '3.0.0',
expiration: new Date('2020-01-01'),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('outdated');
});
});
});
describe('if the server version is not in the list of supported versions', () => {
it('should return as outdated version', () => {
const status = getVersionStatus('2.0.0', [
{
version: '3.0.0',
expiration: new Date(),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('outdated');
});
});
describe('if the server version is in the list of supported versions but is not the highest', () => {
describe('the expiration date is in the future', () => {
it('should return as available version', () => {
const status = getVersionStatus('3.0.0', [
{
version: '3.0.0',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '4.0.0',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('available_version');
});
});
describe('the expiration date is in the past', () => {
it('should return as outdated version', () => {
const status = getVersionStatus('3.0.0', [
{
version: '3.0.0',
expiration: new Date('2020-01-01'),
security: false,
infoUrl: '',
},
{
version: '4.0.0',
expiration: new Date(),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('outdated');
});
});
});
describe('if the server version is not in the list of supported versions but is the highest', () => {
it('should return as latest version', () => {
const status = getVersionStatus('4.0.0', [
{
version: '2.0.0',
expiration: new Date(),
security: false,
infoUrl: '',
},
{
version: '3.0.0',
expiration: new Date(),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('outdated');
expect(status.version).toBe('4.0.0');
});
});
describe('prerelease version', () => {
it('should not suggest available version the highest version is a prerelease from a different tag', () => {
const status = getVersionStatus('3.0.0', [
{
version: '3.0.0',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '4.0.0-develop',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('latest');
expect(status.version).toBe('3.0.0');
});
it('should suggest available version the highest version is a prerelease from the same tag', () => {
const status = getVersionStatus('6.5.0-rc.14', [
{
version: '6.5.0-rc.14',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '6.5.0-rc.15',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '4.0.0-develop',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('available_version');
expect(status.version).toBe('6.5.0-rc.15');
});
it('should suggest available version the highest version even if the current one is a prerelease', () => {
const status = getVersionStatus('6.5.0-rc.14', [
{
version: '6.5.0-rc.14',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '6.5.0-rc.15',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
{
version: '7.0.0',
expiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
security: false,
infoUrl: '',
},
]);
expect(status.label).toBe('available_version');
expect(status.version).toBe('7.0.0');
});
});

View File

@@ -0,0 +1,37 @@
import type { SupportedVersions } from '@rocket.chat/server-cloud-communication';
import semver from 'semver';
import type { VersionStatus } from './components/VersionTag';
export const getVersionStatus = (
serverVersion: string,
versions: SupportedVersions['versions'],
): { label: VersionStatus; expiration: Date | undefined; version: string } => {
const highestVersion = versions
.sort((prev, current) => semver.compare(current.version, prev.version))
.filter((v) => {
const [prerelease] = semver.prerelease(v.version) || [];
const [currentPrerelease] = semver.prerelease(serverVersion) || [];
return !prerelease || prerelease === currentPrerelease;
})[0];
const currentVersionData =
versions.find((v) => v.version === serverVersion) ||
versions.find((v) =>
semver.satisfies(String(semver.coerce(v.version)), `=${semver.major(serverVersion)}.${semver.minor(serverVersion)}.x`),
);
const currentVersionIsExpired = currentVersionData?.expiration && new Date(currentVersionData.expiration) < new Date();
const isSupported = !currentVersionIsExpired && !!currentVersionData;
return {
label: 'outdated',
version: serverVersion,
...(isSupported && semver.gte(currentVersionData?.version, highestVersion.version) && { label: 'latest' }),
...(isSupported &&
semver.gt(highestVersion.version, currentVersionData?.version) && { label: 'available_version', version: highestVersion.version }),
expiration: currentVersionData?.expiration,
};
};

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type featureBulletsType = {
key: number;
title: string;
description: string;
disconnect: string;
};
const useFeatureBullets = () => {
const { t } = useTranslation();
const featureBullets: featureBulletsType[] = useMemo(
() => [
{
key: 1,
title: t('RegisterWorkspace_Features_MobileNotifications_Title'),
description: t('RegisterWorkspace_Features_MobileNotifications_Description'),
disconnect: t('RegisterWorkspace_Features_MobileNotifications_Disconnect'),
},
{
key: 2,
title: t('RegisterWorkspace_Features_Marketplace_Title'),
description: t('RegisterWorkspace_Features_Marketplace_Description'),
disconnect: t('RegisterWorkspace_Features_Marketplace_Disconnect'),
},
{
key: 3,
title: t('RegisterWorkspace_Features_Omnichannel_Title'),
description: t('RegisterWorkspace_Features_Omnichannel_Description'),
disconnect: t('RegisterWorkspace_Features_Omnichannel_Disconnect'),
},
],
[t],
);
return featureBullets;
};
export default useFeatureBullets;

View File

@@ -0,0 +1,85 @@
import {
Box,
Button,
ButtonGroup,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { ExternalLink } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import RegisterWorkspaceSetupModal from './RegisterWorkspaceSetupModal';
import RegisterWorkspaceTokenModal from './RegisterWorkspaceTokenModal';
import { links } from '../../../../../lib/links';
import useFeatureBullets from '../hooks/useFeatureBullets';
export type RegisterWorkspaceModalProps = {
onClose: () => void;
onStatusChange?: () => void;
};
const documentationLink = links.go.registerInfoCollected;
const RegisterWorkspaceModal = ({ onClose, onStatusChange, ...props }: RegisterWorkspaceModalProps) => {
const setModal = useSetModal();
const bulletFeatures = useFeatureBullets();
const { t } = useTranslation();
const handleTokenModal = (): void => {
const handleModalClose = (): void => setModal(null);
setModal(<RegisterWorkspaceTokenModal onClose={handleModalClose} onStatusChange={onStatusChange} />);
};
const handleSetupModal = (): void => {
const handleModalClose = (): void => setModal(null);
setModal(<RegisterWorkspaceSetupModal onClose={handleModalClose} onStatusChange={onStatusChange} />);
};
return (
<Modal {...props}>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('RegisterWorkspace_NotRegistered_Title')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box withRichContent>
<span>{`${t('RegisterWorkspace_NotRegistered_Subtitle')}:`}</span>
<ul>
{bulletFeatures.map((features) => (
<li key={features.key}>
<strong>{features.title}</strong>
<Box is='p' marginBlockStart={4}>
{features.description}
</Box>
</li>
))}
</ul>
<Box is='p' fontSize='p2'>
{t('RegisterWorkspace_Registered_Benefits')}
</Box>
</Box>
</ModalContent>
<ModalFooter>
<Box is='div' display='flex' justifyContent='space-between' alignItems='center' width='full'>
<ExternalLink to={documentationLink}>{t('Learn_more')}</ExternalLink>
<ButtonGroup align='end'>
<Button onClick={handleTokenModal}>{t('Use_token')}</Button>
<Button primary onClick={handleSetupModal}>
{t('RegisterWorkspace_Button')}
</Button>
</ButtonGroup>
</Box>
</ModalFooter>
</Modal>
);
};
export default RegisterWorkspaceModal;

View File

@@ -0,0 +1,71 @@
import { validateEmail } from '@rocket.chat/tools';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useEffect, useState } from 'react';
import RegisteredWorkspaceModal from '../RegisteredWorkspaceModal';
import RegisterWorkspaceSetupStepOneModal from './RegisterWorkspaceSetupStepOneModal';
import RegisterWorkspaceSetupStepTwoModal from './RegisterWorkspaceSetupStepTwoModal';
export type RegisterWorkspaceSetupModalProps = {
onClose: () => void;
onStatusChange?: () => void;
};
const RegisterWorkspaceSetupModal = ({ onClose }: RegisterWorkspaceSetupModalProps) => {
const setModal = useSetModal();
const [step, setStep] = useState(1);
const [email, setEmail] = useState('');
const [terms, setTerms] = useState(false);
const [validInfo, setValidInfo] = useState(false);
const [intentData, setIntentData] = useState({
device_code: '',
interval: 0,
user_code: '',
});
// reset validInfo when users go back to step 1
useEffect(() => {
setValidInfo(false);
}, [step]);
useEffect(() => {
if (step === 1) {
setValidInfo(validateEmail(email) && terms);
}
}, [email, terms]);
const onSuccess = () => {
const handleModalClose = (): void => setModal(null);
setModal(<RegisteredWorkspaceModal onClose={handleModalClose} />);
};
return (
<>
{step === 1 ? (
<RegisterWorkspaceSetupStepOneModal
email={email}
setEmail={setEmail}
step={step}
setStep={setStep}
terms={terms}
setTerms={setTerms}
onClose={onClose}
validInfo={validInfo}
setIntentData={setIntentData}
/>
) : (
<RegisterWorkspaceSetupStepTwoModal
email={email}
step={step}
setStep={setStep}
onClose={onClose}
intentData={intentData}
onSuccess={onSuccess}
/>
)}
</>
);
};
export default RegisterWorkspaceSetupModal;

View File

@@ -0,0 +1,135 @@
import {
Modal,
Box,
Field,
FieldLabel,
FieldRow,
TextInput,
CheckBox,
ButtonGroup,
Button,
ModalHeader,
ModalHeaderText,
ModalTagline,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
} from '@rocket.chat/fuselage';
import { ExternalLink } from '@rocket.chat/ui-client';
import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { links } from '../../../../../../lib/links';
import WorkspaceRegistrationModal from '../RegisterWorkspaceModal';
type Props = {
email: string;
setEmail: (email: string) => void;
step: number;
setStep: (step: number) => void;
terms: boolean;
setTerms: (terms: boolean) => void;
onClose: () => void;
validInfo: boolean;
setIntentData: (intentData: any) => void;
};
const RegisterWorkspaceSetupStepOneModal = ({
email,
setEmail,
step,
setStep,
terms,
setTerms,
onClose,
validInfo,
setIntentData,
...props
}: Props) => {
const setModal = useSetModal();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const createRegistrationIntent = useEndpoint('POST', '/v1/cloud.createRegistrationIntent');
const handleBack = (): void => {
const handleModalClose = (): void => setModal(null);
setModal(<WorkspaceRegistrationModal onClose={handleModalClose} />);
};
const handleRegisterWorkspace = async () => {
try {
const { intentData } = await createRegistrationIntent({ resend: false, email });
setIntentData(intentData);
setStep(step + 1);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const emailField = useId();
const termsField = useId();
return (
<Modal {...props}>
<ModalHeader>
<ModalHeaderText>
<ModalTagline>{t('RegisterWorkspace_Setup_Steps', { step, numberOfSteps: 2 })}</ModalTagline>
<ModalTitle>{t('RegisterWorkspace_with_email')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box>
<Box is='p' fontSize='p2' withRichContent>
{t('RegisterWorkspace_Setup_Subtitle')}
</Box>
<Field paddingBlockStart={10}>
<FieldLabel htmlFor={emailField}>{t('RegisterWorkspace_Setup_Label')}</FieldLabel>
<FieldRow>
<TextInput
id={emailField}
onChange={(e) => {
setEmail((e.target as HTMLInputElement).value);
}}
/>
</FieldRow>
</Field>
<Box marginBlock={16} fontSize='c1'>
<Box is='p'>
<strong>{t('RegisterWorkspace_Setup_Have_Account_Title')}</strong>
</Box>
<Box is='p'>{t('RegisterWorkspace_Setup_Have_Account_Subtitle')}</Box>
<Box is='p' paddingBlockStart={16}>
<strong>{t('RegisterWorkspace_Setup_No_Account_Title')}</strong>
</Box>
<Box is='p'>{t('RegisterWorkspace_Setup_No_Account_Subtitle')}</Box>
</Box>
<Field>
<FieldRow justifyContent='initial'>
<FieldLabel display='block' fontScale='c1' htmlFor={termsField}>
<Trans i18nKey='RegisterWorkspace_Setup_Terms_Privacy'>
I agree with <ExternalLink to={links.terms}>Terms and Conditions</ExternalLink> and{' '}
<ExternalLink to={links.privacy}>Privacy Policy</ExternalLink>
</Trans>
</FieldLabel>
<CheckBox id={termsField} checked={terms} onChange={() => setTerms(!terms)} />
</FieldRow>
</Field>
</Box>
</ModalContent>
<ModalFooter>
<ButtonGroup align='end'>
<Button onClick={handleBack}>{t('Back')}</Button>
<Button primary onClick={handleRegisterWorkspace} disabled={!validInfo}>
{t('Next')}
</Button>
</ButtonGroup>
</ModalFooter>
</Modal>
);
};
export default RegisterWorkspaceSetupStepOneModal;

View File

@@ -0,0 +1,123 @@
import {
Modal,
Box,
Field,
FieldLabel,
FieldRow,
TextInput,
ModalHeader,
ModalHeaderText,
ModalTagline,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
} from '@rocket.chat/fuselage';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect } from 'react';
import { useTranslation, Trans } from 'react-i18next';
type Props = {
email: string;
step: number;
setStep: (step: number) => void;
onClose: () => void;
intentData: {
device_code: string;
interval: number;
user_code: string;
};
onSuccess: () => void;
};
const setIntervalTime = (interval?: number): number => (interval ? interval * 1000 : 0);
const RegisterWorkspaceSetupStepTwoModal = ({ email, step, setStep, onClose, intentData, onSuccess, ...props }: Props) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const cloudConfirmationPoll = useEndpoint('GET', '/v1/cloud.confirmationPoll');
const createRegistrationIntent = useEndpoint('POST', '/v1/cloud.createRegistrationIntent');
const handleBackFromConfirmation = (): void => setStep(step - 1);
const handleResendRegistrationEmail = async () => {
try {
await createRegistrationIntent({ resend: true, email });
dispatchToastMessage({ type: 'success', message: t('Email_sent') });
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const getConfirmation = useCallback(async () => {
try {
const { pollData } = await cloudConfirmationPoll({
deviceCode: intentData.device_code,
});
if ('successful' in pollData && pollData.successful) {
dispatchToastMessage({ type: 'success', message: t('Workspace_registered') });
onSuccess();
}
} catch (error: any) {
console.log(error);
}
}, [cloudConfirmationPoll, intentData.device_code, dispatchToastMessage, t, onSuccess]);
useEffect(() => {
const pollInterval = setInterval(() => getConfirmation(), setIntervalTime(intentData.interval));
return (): void => clearInterval(pollInterval);
}, [getConfirmation, intentData]);
return (
<Modal {...props}>
<ModalHeader>
<ModalHeaderText>
<ModalTagline>{t('RegisterWorkspace_Setup_Steps', { step, numberOfSteps: 2 })}</ModalTagline>
<ModalTitle>{t('Awaiting_confirmation')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box fontSize='p2'>
<Box>
<Trans i18nKey='cloud.RegisterWorkspace_Setup_Email_Confirmation'>
<Box is='p'>
Email sent to{' '}
<Box is='span' fontScale='p2b'>
{email}{' '}
</Box>
with a confirmation link.
</Box>
</Trans>
</Box>
<Box is='p'>{t('RegisterWorkspace_Setup_Email_Verification')}</Box>
<Field paddingBlockStart={10}>
<FieldLabel>{t('Security_code')}</FieldLabel>
<FieldRow>
<TextInput defaultValue={intentData.user_code} disabled />
</FieldRow>
</Field>
</Box>
</ModalContent>
<ModalFooter>
<Box is='div' display='flex' justifyContent='start' fontSize='c1' width='full'>
<Trans i18nKey='RegisterWorkspace_Didnt_Receive_Email'>
Didn't receive email?{' '}
<Box is='a' paddingInline={4} onClick={handleResendRegistrationEmail}>
Resend
</Box>{' '}
or{' '}
<Box is='a' paddingInline={4} onClick={handleBackFromConfirmation}>
change email
</Box>
</Trans>
</Box>
</ModalFooter>
</Modal>
);
};
export default RegisterWorkspaceSetupStepTwoModal;

View File

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

View File

@@ -0,0 +1,113 @@
import {
Box,
Button,
ButtonGroup,
Field,
FieldLabel,
FieldRow,
FieldError,
Modal,
TextInput,
ModalHeader,
ModalHeaderText,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
} from '@rocket.chat/fuselage';
import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import WorkspaceRegistrationModal from './RegisterWorkspaceModal';
export type RegisterWorkspaceTokenModalProps = {
onClose: () => void;
onStatusChange?: () => void;
};
const RegisterWorkspaceTokenModal = ({ onClose, onStatusChange, ...props }: RegisterWorkspaceTokenModalProps) => {
const setModal = useSetModal();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const connectWorkspace = useEndpoint('POST', '/v1/cloud.connectWorkspace');
const [token, setToken] = useState('');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState(false);
const handleBackAction = (): void => {
const handleModalClose = (): void => setModal(null);
setModal(<WorkspaceRegistrationModal onClose={handleModalClose} />);
};
const handleTokenChange = (event: ChangeEvent<HTMLInputElement>) => {
setToken(event.target.value);
};
const isToken = token.length > 0;
const handleConnectButtonClick = async (): Promise<void> => {
setProcessing(true);
setError(false);
try {
await connectWorkspace({ token });
setModal(null);
dispatchToastMessage({ type: 'success', message: t('Connected') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
setError(true);
} finally {
await onStatusChange?.();
setProcessing(false);
}
};
return (
<Modal {...props}>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('RegisterWorkspace_Token_Title')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box is='p'>
<Trans i18nKey='RegisterWorkspace_Token_Step_One'>
1. Go to:{' '}
<Box is='span' fontWeight={600}>
cloud.rocket.chat {'>'} Workspaces
</Box>{' '}
and click{' '}
<Box is='span' fontWeight={600}>
"Register self-managed"
</Box>
.
</Trans>
</Box>
<Box is='p' fontSize='p2'>{`2. ${t('RegisterWorkspace_Token_Step_Two')}`}</Box>
<Field paddingBlockStart={10}>
<FieldLabel>{t('Registration_Token')}</FieldLabel>
<FieldRow>
<TextInput onChange={handleTokenChange} value={token} />
</FieldRow>
{error && <FieldError>{t('Token_Not_Recognized')}</FieldError>}
</Field>
</ModalContent>
<ModalFooter>
<ButtonGroup align='end'>
<Button onClick={handleBackAction}>{t('Back')}</Button>
<Button primary disabled={!isToken} loading={processing} onClick={handleConnectButtonClick}>
{t('Next')}
</Button>
</ButtonGroup>
</ModalFooter>
</Modal>
);
};
export default RegisterWorkspaceTokenModal;

View File

@@ -0,0 +1,89 @@
import {
Box,
Button,
ButtonGroup,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useFeatureBullets from '../hooks/useFeatureBullets';
export type RegisteredWorkspaceModalProps = {
onClose: () => void;
onStatusChange?: () => void;
};
const RegisteredWorkspaceModal = ({ onClose, onStatusChange, ...props }: RegisteredWorkspaceModalProps) => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const bulletFeatures = useFeatureBullets();
const [isSyncing, setSyncing] = useSafely(useState(false));
const syncWorkspace = useEndpoint('POST', '/v1/cloud.syncWorkspace');
const handleSyncAction = async () => {
setSyncing(true);
try {
const { success } = await syncWorkspace();
if (!success) {
throw Error(t('RegisterWorkspace_Syncing_Error'));
}
dispatchToastMessage({ type: 'success', message: t('RegisterWorkspace_Syncing_Complete') });
setModal(null);
} catch (error) {
const message = error instanceof Error ? error.message : t('RegisterWorkspace_Syncing_Error');
dispatchToastMessage({ type: 'error', message });
} finally {
onStatusChange?.();
setSyncing(false);
}
};
return (
<Modal {...props}>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('Workspace_registered')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box withRichContent>
<span>{`${t('RegisterWorkspace_Registered_Subtitle')}: `}</span>
<ul>
{bulletFeatures.map((item, index) => (
<li key={index}>
<strong>{item.title}</strong>
<Box is='p' marginBlockStart={4}>
{item.description}
</Box>
</li>
))}
</ul>
</Box>
</ModalContent>
<ModalFooter>
<ButtonGroup align='end'>
<Button icon='reload' onClick={handleSyncAction} loading={isSyncing}>
{t('Sync')}
</Button>
</ButtonGroup>
</ModalFooter>
</Modal>
);
};
export default RegisteredWorkspaceModal;

View File

@@ -0,0 +1,71 @@
import type { IWorkspaceInfo, IStats } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup, Callout, CardGrid } from '@rocket.chat/fuselage';
import type { IInstance } from '@rocket.chat/rest-typings';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import DeploymentCard from './DeploymentCard/DeploymentCard';
import MessagesRoomsCard from './MessagesRoomsCard/MessagesRoomsCard';
import UsersUploadsCard from './UsersUploadsCard/UsersUploadsCard';
import VersionCard from './VersionCard/VersionCard';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
type WorkspaceStatusPageProps = {
canViewStatistics: boolean;
serverInfo: IWorkspaceInfo;
statistics: IStats;
statisticsIsLoading: boolean;
instances: IInstance[];
onClickRefreshButton: () => void;
onClickDownloadInfo: () => void;
};
const WorkspacePage = ({
canViewStatistics,
serverInfo,
statistics,
statisticsIsLoading,
instances,
onClickRefreshButton,
onClickDownloadInfo,
}: WorkspaceStatusPageProps) => {
const { t } = useTranslation();
const { data } = useIsEnterprise();
const warningMultipleInstances = !data?.isEnterprise && !statistics?.msEnabled && statistics?.instanceCount > 1;
return (
<Page background='tint'>
<PageHeader title={t('Workspace')}>
{canViewStatistics && (
<ButtonGroup>
<Button onClick={onClickDownloadInfo}>{t('Download_Info')}</Button>
<Button onClick={onClickRefreshButton} loading={statisticsIsLoading}>
{t('Refresh')}
</Button>
</ButtonGroup>
)}
</PageHeader>
<PageScrollableContentWithShadow padding={16}>
<Box marginBlock='none' marginInline='auto' width='full' color='default'>
{warningMultipleInstances && (
<Callout type='warning' title={t('Multiple_monolith_instances_alert')} marginBlockEnd={16}></Callout>
)}
<Box marginBlockEnd={16}>
<VersionCard serverInfo={serverInfo} />
</Box>
<CardGrid breakpoints={{ lg: 4, xs: 4, padding: 8 }}>
<DeploymentCard serverInfo={serverInfo} statistics={statistics} instances={instances} />
<UsersUploadsCard statistics={statistics} />
<MessagesRoomsCard statistics={statistics} />
</CardGrid>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
};
export default memo(WorkspacePage);

View File

@@ -0,0 +1,67 @@
import { Callout, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { usePermission } from '@rocket.chat/ui-contexts';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import WorkspacePage from './WorkspacePage';
import PageSkeleton from '../../../components/PageSkeleton';
import { useWorkspaceInfo } from '../../../hooks/useWorkspaceInfo';
import { downloadJsonAs } from '../../../lib/download';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const WorkspaceRoute = () => {
const { t } = useTranslation();
const canViewStatistics = usePermission('view-statistics');
const [refreshStatistics, setRefreshStatistics] = useState(false);
const [serverInfoQuery, instancesQuery, statisticsQuery] = useWorkspaceInfo({ refreshStatistics });
if (!canViewStatistics) {
return <NotAuthorizedPage />;
}
if (serverInfoQuery.isPending || instancesQuery.isPending || statisticsQuery.isPending) {
return <PageSkeleton />;
}
const handleClickRefreshButton = async () => {
setRefreshStatistics(true);
statisticsQuery.refetch();
};
const handleClickDownloadInfo = (): void => {
downloadJsonAs(statisticsQuery.data, 'statistics');
};
if (serverInfoQuery.isError || instancesQuery.isError || statisticsQuery.isError) {
return (
<Page>
<PageHeader title={t('Workspace')}>
<ButtonGroup>
<Button icon='reload' primary type='button' onClick={handleClickRefreshButton} loading={statisticsQuery.isPending}>
{t('Refresh')}
</Button>
</ButtonGroup>
</PageHeader>
<PageScrollableContentWithShadow>
<Callout type='danger'>{t('Error_loading_pages')}</Callout>
</PageScrollableContentWithShadow>
</Page>
);
}
return (
<WorkspacePage
canViewStatistics={canViewStatistics}
serverInfo={serverInfoQuery.data}
statistics={statisticsQuery.data}
statisticsIsLoading={statisticsQuery.isPending}
instances={instancesQuery.data}
onClickRefreshButton={handleClickRefreshButton}
onClickDownloadInfo={handleClickDownloadInfo}
/>
);
};
export default memo(WorkspaceRoute);

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type WorkspaceCardSectionProps = {
children: ReactNode;
};
const WorkspaceCardSection = ({ children }: WorkspaceCardSectionProps) => <Box fontScale='p2'>{children}</Box>;
export default WorkspaceCardSection;

View File

@@ -0,0 +1,14 @@
import { Box } from '@rocket.chat/fuselage';
export type WorkspaceCardSectionTitleProps = {
variant?: 'p2b' | 'h4';
title: string;
};
const WorkspaceCardSectionTitle = ({ variant = 'p2b', title }: WorkspaceCardSectionTitleProps) => (
<Box fontScale={variant} marginBlockEnd={variant === 'h4' ? 20 : 0}>
{title}
</Box>
);
export default WorkspaceCardSectionTitle;

View File

@@ -0,0 +1,29 @@
import { Box, Icon, StatusBullet } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import { TextSeparator } from '@rocket.chat/ui-client';
import type { ComponentProps, ReactNode } from 'react';
export type WorkspaceCardTextSeparatorProps = {
label: ReactNode;
icon?: Keys;
status?: ComponentProps<typeof StatusBullet>['status'];
value: ReactNode;
};
const WorkspaceCardTextSeparator = ({ icon, label, value, status }: WorkspaceCardTextSeparatorProps) => (
<TextSeparator
label={
<>
{icon && <Icon name={icon} size='x16' marginInlineEnd={4} />}
{status && (
<Box minWidth='x16' display='inline-flex' flexDirection='row' alignItems='flex-end' justifyContent='center'>
<StatusBullet status={status} />
</Box>
)}
{label && label}
</>
}
value={value}
/>
);
export default WorkspaceCardTextSeparator;