Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adds callout from cloud for subscription upgrade eligibility #33549

Merged
merged 21 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7896d38
feat: create `UiKitSubscriptionLicenseSurface`
ggazzo Nov 20, 2024
dff7957
chore: adds logic to handle cloud announcement data
ggazzo Nov 20, 2024
bd9e14c
fix: improves cloud announcement handling
aleksandernsilva Oct 18, 2024
08d6453
chore: create CloudSubscriptionCommunication app service
ggazzo Dec 9, 2024
09668d2
chore: adds minor improvements to useLicenseWithCloudAnnouncement
lucas-a-pelegrino Oct 21, 2024
76e9cab
chore: isolates CloudSyncAnnouncement to be reused as needed
lucas-a-pelegrino Oct 22, 2024
34a8b59
refactor: minor improvements to cloudSyncAnnouncement logic
lucas-a-pelegrino Oct 29, 2024
7fb1b8c
refactor: extracts fetchWorkspaceSyncPayload() function to its own file
lucas-a-pelegrino Nov 1, 2024
834868d
tests: adds unit tests for syncCloudData
lucas-a-pelegrino Nov 1, 2024
4cefc7b
doc: adds changeset
lucas-a-pelegrino Nov 20, 2024
f6ece87
mock
ggazzo Oct 19, 2024
d423071
Revert "mock"
ggazzo Nov 20, 2024
d651af2
docs: updates changeset to meet guidelines
lucas-a-pelegrino Dec 11, 2024
d11b294
style: removes eslint-disable line from file
lucas-a-pelegrino Dec 11, 2024
6fd40dc
style: removes spacing changes
lucas-a-pelegrino Dec 11, 2024
cacc642
fix: typos and interface naming
lucas-a-pelegrino Dec 11, 2024
ca8f11b
style: fix typo on comment
lucas-a-pelegrino Dec 11, 2024
f5e7ef6
chore: refactors trycatch to handle errors more gracefully
lucas-a-pelegrino Dec 12, 2024
942db81
Merge branch 'chore/bump-storybok-fuselage-ui-kit-cloud-surface' of g…
lucas-a-pelegrino Dec 16, 2024
fcd596a
Merge branch 'develop' into chore/bump-storybok-fuselage-ui-kit-cloud…
kodiakhq[bot] Dec 17, 2024
3a49c7d
Merge branch 'develop' into chore/bump-storybok-fuselage-ui-kit-cloud…
kodiakhq[bot] Dec 17, 2024
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/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}`,
lucas-a-pelegrino marked this conversation as resolved.
Show resolved Hide resolved
},
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));

KevLehman marked this conversation as resolved.
Show resolved Hide resolved
if (removeLicense) {
await callbacks.run('workspaceLicenseRemoved');
} else {
Expand Down
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,
};
};
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
Loading