Skip to content

Commit

Permalink
feat: Adds callout from cloud for subscription upgrade eligibility (#…
Browse files Browse the repository at this point in the history
…33549)

Co-authored-by: Aleksander Nicacio da Silva <aleksander.silva@rocket.chat>
Co-authored-by: Lucas Pelegrino <lucas.pelegrino@rocket.chat>
Co-authored-by: Kevin Aleman <kaleman960@gmail.com>
  • Loading branch information
4 people authored and MartinSchoeler committed Dec 30, 2024
1 parent 064f6d3 commit 200db1d
Show file tree
Hide file tree
Showing 20 changed files with 407 additions and 60 deletions.
7 changes: 7 additions & 0 deletions .changeset/wicked-socks-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": minor
"@rocket.chat/rest-typings": minor
---

Adds a new callout in the subscription page to inform users of subscription upgrade eligibility when applicable.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Cloud, Serialized } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { v, compile } from 'suretype';

import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError';
import { settings } from '../../../../settings/server';

const workspaceSyncPayloadSchema = v.object({
workspaceId: v.string().required(),
publicKey: v.string(),
license: v.string().required(),
});

const assertWorkspaceSyncPayload = compile(workspaceSyncPayloadSchema);

export async function fetchWorkspaceSyncPayload({
token,
data,
}: {
token: string;
data: Cloud.WorkspaceSyncRequestPayload;
}): Promise<Serialized<Cloud.WorkspaceSyncResponse>> {
const workspaceRegistrationClientUri = settings.get<string>('Cloud_Workspace_Registration_Client_Uri');
const response = await fetch(`${workspaceRegistrationClientUri}/sync`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: data,
});

if (!response.ok) {
const { error } = await response.json();
throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`);
}

const payload = await response.json();

assertWorkspaceSyncPayload(payload);

return payload;
}
Original file line number Diff line number Diff line change
@@ -1,57 +1,14 @@
import type { Cloud, Serialized } from '@rocket.chat/core-typings';
import { DuplicatedLicenseError } from '@rocket.chat/license';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { v, compile } from 'suretype';
import { Settings } from '@rocket.chat/models';

import { callbacks } from '../../../../../lib/callbacks';
import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError';
import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError';
import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError';
import { SystemLogger } from '../../../../../server/lib/logger/system';
import { settings } from '../../../../settings/server';
import { buildWorkspaceRegistrationData } from '../buildRegistrationData';
import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from '../getWorkspaceAccessToken';
import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus';

const workspaceSyncPayloadSchema = v.object({
workspaceId: v.string().required(),
publicKey: v.string(),
license: v.string().required(),
});

const assertWorkspaceSyncPayload = compile(workspaceSyncPayloadSchema);

const fetchWorkspaceSyncPayload = async ({
token,
data,
}: {
token: string;
data: Cloud.WorkspaceSyncRequestPayload;
}): Promise<Serialized<Cloud.WorkspaceSyncResponse>> => {
const workspaceRegistrationClientUri = settings.get<string>('Cloud_Workspace_Registration_Client_Uri');
const response = await fetch(`${workspaceRegistrationClientUri}/sync`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: data,
});

if (!response.ok) {
try {
const { error } = await response.json();
throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`);
} catch (error) {
throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`);
}
}

const payload = await response.json();

assertWorkspaceSyncPayload(payload);

return payload;
};
import { fetchWorkspaceSyncPayload } from './fetchWorkspaceSyncPayload';

export async function syncCloudData() {
try {
Expand All @@ -67,11 +24,17 @@ export async function syncCloudData() {

const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined);

const { license, removeLicense = false } = await fetchWorkspaceSyncPayload({
const {
license,
removeLicense = false,
cloudSyncAnnouncement,
} = await fetchWorkspaceSyncPayload({
token,
data: workspaceRegistrationData,
});

await Settings.updateValueById('Cloud_Sync_Announcement_Payload', JSON.stringify(cloudSyncAnnouncement ?? null));

if (removeLicense) {
await callbacks.run('workspaceLicenseRemoved');
} else {
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/client/components/Page/PageHeaderNoShadow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const PageHeaderNoShadow = ({ children = undefined, title, onClickBack, ...props
useDocumentTitle(typeof title === 'string' ? title : undefined);

return (
<Box is='header' borderBlockEndWidth='default' borderBlockEndColor='transparent' {...props}>
<Box is='header' borderBlockEndWidth='default' pb={8} borderBlockEndColor='transparent' {...props}>
<Box
height='100%'
marginInline={24}
Expand Down
15 changes: 14 additions & 1 deletion apps/meteor/client/hooks/useLicense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,20 @@ export const useLicenseBase = <TData = LicenseDataType>({
};

export const useLicense = (params?: LicenseParams) => {
return useLicenseBase({ params, select: (data) => data.license });
return useLicenseBase({
params,
select: (data) => data.license,
});
};

export const useLicenseWithCloudAnnouncement = (params?: LicenseParams) => {
return useLicenseBase({
params,
select: ({ license, cloudSyncAnnouncement }) => ({
...license,
cloudSyncAnnouncement,
}),
});
};

export const useHasLicense = (): UseQueryResult<boolean> => {
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/client/uikit/hooks/useBannerContextValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const useBannerContextValue = ({ view, values }: UseBannerContextValuePar
},
updateState: (): void => undefined,
appId: view.appId,
viewId: view.viewId,
values,
};
};
20 changes: 14 additions & 6 deletions apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import PlanCardCommunity from './components/cards/PlanCard/PlanCardCommunity';
import SeatsCard from './components/cards/SeatsCard';
import { useCancelSubscriptionModal } from './hooks/useCancelSubscriptionModal';
import { useWorkspaceSync } from './hooks/useWorkspaceSync';
import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page';
import UiKitSubscriptionLicense from './surface/UiKitSubscriptionLicense';
import { Page, PageScrollableContentWithShadow } from '../../../components/Page';
import PageBlockWithBorder from '../../../components/Page/PageBlockWithBorder';
import PageHeaderNoShadow from '../../../components/Page/PageHeaderNoShadow';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import { useInvalidateLicense, useLicense } from '../../../hooks/useLicense';
import { useInvalidateLicense, useLicenseWithCloudAnnouncement } from '../../../hooks/useLicense';
import { useRegistrationStatus } from '../../../hooks/useRegistrationStatus';

function useShowLicense() {
Expand All @@ -48,15 +51,15 @@ const SubscriptionPage = () => {
const router = useRouter();
const { data: enterpriseData } = useIsEnterprise();
const { isRegistered } = useRegistrationStatus();
const { data: licensesData, isLoading: isLicenseLoading } = useLicense({ loadValues: true });
const { data: licensesData, isLoading: isLicenseLoading } = useLicenseWithCloudAnnouncement({ loadValues: true });
const syncLicenseUpdate = useWorkspaceSync();
const invalidateLicenseQuery = useInvalidateLicense();

const subscriptionSuccess = useSearchParameter('subscriptionSuccess');

const showSubscriptionCallout = useDebouncedValue(subscriptionSuccess || syncLicenseUpdate.isLoading, 10000);

const { license, limits, activeModules = [] } = licensesData || {};
const { license, limits, activeModules = [], cloudSyncAnnouncement } = licensesData || {};
const { isEnterprise = true } = enterpriseData || {};

const getKeyLimit = (key: 'monthlyActiveContacts' | 'activeUsers') => {
Expand Down Expand Up @@ -99,7 +102,7 @@ const SubscriptionPage = () => {

return (
<Page bg='tint'>
<PageHeader title={t('Subscription')}>
<PageHeaderNoShadow title={t('Subscription')}>
<ButtonGroup>
{isRegistered && (
<Button loading={syncLicenseUpdate.isLoading} icon='reload' onClick={() => handleSyncLicenseUpdate()}>
Expand All @@ -110,7 +113,12 @@ const SubscriptionPage = () => {
{t(isEnterprise ? 'Manage_subscription' : 'Upgrade')}
</UpgradeButton>
</ButtonGroup>
</PageHeader>
</PageHeaderNoShadow>
{cloudSyncAnnouncement && (
<PageBlockWithBorder>
<UiKitSubscriptionLicense key='license' initialView={cloudSyncAnnouncement} />
</PageBlockWithBorder>
)}
<PageScrollableContentWithShadow p={16}>
{(showSubscriptionCallout || syncLicenseUpdate.isLoading) && (
<Callout type='info' title={t('Sync_license_update_Callout_Title')} m={8}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { UiKitContext, bannerParser, UiKitComponent } from '@rocket.chat/fuselage-ui-kit';
import type { View } from '@rocket.chat/ui-kit';
import type { ContextType, Dispatch, ReactElement } from 'react';
import React, { useMemo } from 'react';

import type { SubscriptionLicenseLayout } from './UiKitSubscriptionLicenseSurface';
import { UiKitSubscriptionLicenseSurface } from './UiKitSubscriptionLicenseSurface';
import MarkdownText from '../../../../components/MarkdownText';
import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager';
import { useUiKitView } from '../../../../uikit/hooks/useUiKitView';

// TODO: move this to fuselage-ui-kit itself
bannerParser.mrkdwn = ({ text }): ReactElement => <MarkdownText variant='inline' content={text} />;

type UiKitSubscriptionLicenseProps = {
key: string;
initialView: {
viewId: string;
appId: string;
blocks: SubscriptionLicenseLayout;
};
};

type UseSubscriptionLicenseContextValueParams = {
view: View & {
viewId: string;
};
values: {
[actionId: string]: {
value: unknown;
blockId?: string | undefined;
};
};
updateValues: Dispatch<{
actionId: string;
payload: {
value: unknown;
blockId?: string | undefined;
};
}>;
};
type UseSubscriptionLicenseContextValueReturn = ContextType<typeof UiKitContext>;

const useSubscriptionLicenseContextValue = ({
view,
values,
updateValues,
}: UseSubscriptionLicenseContextValueParams): UseSubscriptionLicenseContextValueReturn => {
const actionManager = useUiKitActionManager();

const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]);
const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700);

return {
action: async ({ appId, viewId, actionId, dispatchActionConfig, blockId, value }): Promise<void> => {
if (!appId || !viewId) {
return;
}

const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction;

await emit(appId, {
type: 'blockAction',
actionId,
container: {
type: 'view',
id: viewId,
},
payload: {
blockId,
value,
},
});
},
updateState: ({ actionId, value, blockId = 'default' }) => {
updateValues({
actionId,
payload: {
blockId,
value,
},
});
},
...view,
values,
viewId: view.viewId,
};
};

const UiKitSubscriptionLicense = ({ initialView }: UiKitSubscriptionLicenseProps) => {
const { view, values, updateValues } = useUiKitView(initialView);
const contextValue = useSubscriptionLicenseContextValue({ view, values, updateValues });

return (
<UiKitContext.Provider value={contextValue}>
<UiKitComponent render={UiKitSubscriptionLicenseSurface} blocks={view.blocks} />
</UiKitContext.Provider>
);
};

export default UiKitSubscriptionLicense;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Margins } from '@rocket.chat/fuselage';
import { createSurfaceRenderer, Surface, FuselageSurfaceRenderer, renderTextObject } from '@rocket.chat/fuselage-ui-kit';
import type { CalloutBlock, ContextBlock, DividerBlock, ImageBlock, SectionBlock } from '@rocket.chat/ui-kit';
import type { ReactElement, ReactNode } from 'react';
import React from 'react';

type SubscriptionLicenseSurfaceProps = {
children?: ReactNode;
};

type SubscriptionLicenseLayoutBlock = ContextBlock | DividerBlock | ImageBlock | SectionBlock | CalloutBlock;

export type SubscriptionLicenseLayout = SubscriptionLicenseLayoutBlock[];

const SubscriptionLicenseSurface = ({ children }: SubscriptionLicenseSurfaceProps): ReactElement => (
<Surface type='custom'>
<Margins blockEnd={16}>{children}</Margins>
</Surface>
);

export class SubscriptionLicenseSurfaceRenderer extends FuselageSurfaceRenderer {
public constructor() {
super(['context', 'divider', 'image', 'section', 'callout']);
}

plain_text = renderTextObject;

mrkdwn = renderTextObject;
}

export default SubscriptionLicenseSurface;

export const UiKitSubscriptionLicenseSurface = createSurfaceRenderer(SubscriptionLicenseSurface, new SubscriptionLicenseSurfaceRenderer());
23 changes: 21 additions & 2 deletions apps/meteor/ee/server/api/licenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { check } from 'meteor/check';
import { API } from '../../../app/api/server/api';
import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission';
import { notifyOnSettingChangedById } from '../../../app/lib/server/lib/notifyListener';
import { settings } from '../../../app/settings/server';
import { updateAuditedByUser } from '../../../server/settings/lib/auditedSettingUpdates';

API.v1.addRoute(
Expand All @@ -16,9 +17,27 @@ API.v1.addRoute(
const unrestrictedAccess = await hasPermissionAsync(this.userId, 'view-privileged-setting');
const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues);

const license = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues });
const license = await License.getInfo({
limits: unrestrictedAccess,
license: unrestrictedAccess,
currentValues: loadCurrentValues,
});

try {
// TODO: Remove this logic after setting type object is implemented.
const cloudSyncAnnouncement = JSON.parse(settings.get('Cloud_Sync_Announcement_Payload') ?? null);
const canManageCloud = await hasPermissionAsync(this.userId, 'manage-cloud');
return API.v1.success({
license,
...(canManageCloud && cloudSyncAnnouncement && { cloudSyncAnnouncement }),
});
} catch (error) {
console.error('Unable to parse Cloud_Sync_Announcement_Payload');
}

return API.v1.success({ license });
return API.v1.success({
license,
});
},
},
);
Expand Down
Loading

0 comments on commit 200db1d

Please sign in to comment.