diff --git a/app/lib/common.ts b/app/lib/common.ts index c5a1b2d3..4ac73955 100644 --- a/app/lib/common.ts +++ b/app/lib/common.ts @@ -8,7 +8,10 @@ const { ENTERPRISE_API_URL } = process.env; export async function validateLicense() { try { - return (await axios.post(`${ENTERPRISE_API_URL}/api/v1/subscription/validate`)).data; + const response = await axios.post( + `${ENTERPRISE_API_URL}/api/v1/subscription/validate`, + ); + return response.data; } catch (error) { // supressing error. license not found return {} as License; diff --git a/components/Tag/Tag.styled.ts b/components/Tag/Tag.styled.ts index b734b430..7d5b286d 100644 --- a/components/Tag/Tag.styled.ts +++ b/components/Tag/Tag.styled.ts @@ -56,8 +56,9 @@ export const TagContainer = styled(Row)<{ bg?: string; textColor?: string }>` `; export const StyledText = muiStyled(Typography)` - textTransform: 'initial', - display: 'flex', - alignItems: 'center', - gap: 1, + text-transform: initial; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; `; diff --git a/containers/ClusterForms/ClusterCreationForm/ClusterCreationForm.tsx b/containers/ClusterForms/ClusterCreationForm/ClusterCreationForm.tsx index 95623eaa..1f212714 100644 --- a/containers/ClusterForms/ClusterCreationForm/ClusterCreationForm.tsx +++ b/containers/ClusterForms/ClusterCreationForm/ClusterCreationForm.tsx @@ -1,7 +1,6 @@ import React, { ComponentProps, FC, useCallback, useEffect, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import Box from '@mui/material/Box'; -import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined'; import { usePhysicalClustersPermissions } from '../../../hooks/usePhysicalClustersPermission'; @@ -13,7 +12,7 @@ import ControlledTextField from '@/components/controlledFields/ControlledTextFie import { useAppDispatch, useAppSelector } from '@/redux/store'; import Typography from '@/components/Typography/Typography'; import { ClusterType, NewWorkloadClusterConfig, ClusterEnvironment } from '@/types/provision'; -import { ASMANI_SKY, BISCAY, EXCLUSIVE_PLUM } from '@/constants/colors'; +import { BISCAY, EXCLUSIVE_PLUM } from '@/constants/colors'; import ControlledNumberInput from '@/components/controlledFields/ControlledNumberInput/ControlledNumberInput'; import ControlledRadioGroup from '@/components/controlledFields/ControlledRadioGroup/ControlledRadioGroup'; import { @@ -37,14 +36,7 @@ import { getRegionZones, } from '@/redux/thunks/api.thunk'; import ControlledTagsAutocomplete from '@/components/controlledFields/ControlledAutoComplete/ControlledTagsAutoComplete'; -import { - selectHasLicenseKey, - selectIsLicenseActive, -} from '@/redux/selectors/subscription.selector'; -import Tag from '@/components/Tag/Tag'; import { getCloudProviderAuth } from '@/utils/getCloudProviderAuth'; -import { FeatureFlag } from '@/types/config'; -import useFeatureFlag from '@/hooks/useFeatureFlag'; import { selectApiState } from '@/redux/selectors/api.selector'; import { selectEnvironmentsState } from '@/redux/selectors/environments.selector'; @@ -54,9 +46,6 @@ const ClusterCreationForm: FC> = (props) => { selectApiState(), ); const { environments, error } = useAppSelector(selectEnvironmentsState()); - const hasLicenseKey = useAppSelector(selectHasLicenseKey()); - const isLicenseActive = useAppSelector(selectIsLicenseActive()); - const { isEnabled: isSubscriptionEnabled } = useFeatureFlag(FeatureFlag.SAAS_SUBSCRIPTION); const dispatch = useAppDispatch(); @@ -155,10 +144,6 @@ const ClusterCreationForm: FC> = (props) => { const draftCluster = clusterMap[RESERVED_DRAFT_CLUSTER_NAME]; const isVCluster = type === ClusterType.WORKLOAD_V_CLUSTER; - const handleRedirect = (): void => { - window.open(`${location.origin}/settings/subscription/plans`, '_blank'); - }; - const clusterOptions = useMemo(() => { let clusterTypes; @@ -170,31 +155,8 @@ const ClusterCreationForm: FC> = (props) => { ); } - if (!isSubscriptionEnabled) { - return clusterTypes; - } - - return hasLicenseKey && isLicenseActive - ? clusterTypes - : clusterTypes.map((option) => { - if (option.value === ClusterType.WORKLOAD) { - return { - ...option, - isDisabled: true, - tag: ( - } - /> - ), - }; - } - - return option; - }); - }, [hasLicenseKey, hasPermissions, isLicenseActive, isSubscriptionEnabled]); + return clusterTypes; + }, [hasPermissions]); useEffect(() => { const subscription = watch((values) => { diff --git a/containers/ClusterManagement/ClusterManagement.tsx b/containers/ClusterManagement/ClusterManagement.tsx index 7167b893..315d6193 100644 --- a/containers/ClusterManagement/ClusterManagement.tsx +++ b/containers/ClusterManagement/ClusterManagement.tsx @@ -46,6 +46,7 @@ import KubeConfigModal from '@/components/KubeConfigModal/KubeConfigModal'; import { createNotification } from '@/redux/slices/notifications.slice'; import useFeatureFlag from '@/hooks/useFeatureFlag'; import Column from '@/components/Column/Column'; +import { SaasFeatures } from '@/types/subscription'; const ClusterManagement: FunctionComponent = () => { const { @@ -173,22 +174,17 @@ const ClusterManagement: FunctionComponent = () => { }, [clusterCreationStep, managementCluster, dispatch, openCreateClusterFlow]); const handleCreateCluster = () => { - const draftCluster = clusterMap[RESERVED_DRAFT_CLUSTER_NAME]; - - if ( - draftCluster?.type === ClusterType.WORKLOAD && - clusterCreationStep !== ClusterCreationStep.DETAILS - ) { - const canCreatePhysicalClusters = canUseFeature('physicalClusters'); + if (clusterCreationStep !== ClusterCreationStep.DETAILS) { + const canCreateWorkloadClusters = canUseFeature(SaasFeatures.WorkloadClustersLimit); - if (isSassSubscriptionEnabled && !canCreatePhysicalClusters) { + if (isSassSubscriptionEnabled && !canCreateWorkloadClusters) { return openUpgradeModal(); } } - if (clusterCreationStep !== ClusterCreationStep.DETAILS) { - dispatch(createWorkloadCluster()); - } + // if (clusterCreationStep !== ClusterCreationStep.DETAILS) { + // dispatch(createWorkloadCluster()); + // } }; const handleDeleteMenuClick = useCallback( diff --git a/hooks/__tests__/usePaywall.test.ts b/hooks/__tests__/usePaywall.test.ts new file mode 100644 index 00000000..4a9dd664 --- /dev/null +++ b/hooks/__tests__/usePaywall.test.ts @@ -0,0 +1,178 @@ +import { renderHook } from '@testing-library/react'; + +import usePaywall from '../usePaywall'; +// eslint-disable-next-line import/order +import { SaasFeatures, SaasPlans } from '../../types/subscription'; + +jest.mock('@/redux/store', () => ({ + useAppSelector: jest.fn(), +})); + +import { useAppSelector } from '../../redux/store'; + +describe('usePaywall', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the correct plan', () => { + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { plan: { name: SaasPlans.Pro }, is_active: true, clusters: [] }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.plan).toBe(SaasPlans.Pro); + }); + + it('should return active clusters', () => { + const activeClusters = [ + { id: 1, isActive: true }, + { id: 2, isActive: true }, + ]; + + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { plan: { name: SaasPlans.Pro }, is_active: true, clusters: activeClusters }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.activeClusters).toEqual(activeClusters); + }); + + it('should return false if the feature is not available', () => { + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { plan: { name: SaasPlans.Pro, features: [] }, is_active: true, clusters: [] }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature('some_feature')).toBe(false); + }); + + describe('pro plan', () => { + it('should return true if the feature is available', () => { + const featureCode = 'some_feature'; + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { + plan: { name: SaasPlans.Pro, features: [{ code: featureCode }] }, + is_active: true, + clusters: [], + }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature(featureCode)).toBe(true); + }); + + it('should allow feature if cluster limit is not exceeded', () => { + const activeClusters = [ + { id: 1, isActive: true }, + { id: 2, isActive: true }, + ]; + const featureCode = SaasFeatures.WorkloadClustersLimit; + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { + plan: { + name: SaasPlans.Pro, + features: [{ code: featureCode, data: { limit: 3 } }], + }, + is_active: true, + clusters: activeClusters, + }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature(featureCode)).toBe(true); + }); + + it('should not allow feature if cluster limit has exceeded', () => { + const activeClusters = [ + { id: 1, isActive: true }, + { id: 2, isActive: true }, + { id: 2, isActive: true }, + { id: 2, isActive: true }, + ]; + const featureCode = SaasFeatures.WorkloadClustersLimit; + const mockState = { + api: { clusterMap: {} }, + subscription: { + license: { + plan: { + name: SaasPlans.Pro, + features: [{ code: featureCode, data: { limit: 3 } }], + }, + is_active: true, + clusters: activeClusters, + }, + }, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature(featureCode)).toBe(false); + }); + }); + + describe('community plan', () => { + it('should enforce cluster limit for Community plan without license key', () => { + const clusterMap = { + cluster1: { id: 1 }, + cluster2: { id: 2 }, + cluster3: { id: 3 }, + cluster4: { id: 4 }, + }; + + const mockState = { + api: { clusterMap }, + subscription: {}, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature(SaasFeatures.WorkloadClustersLimit)).toBe(false); + }); + + it('should allow use feature for Community plan without license key when the clusters are less than 3', () => { + const clusterMap = { + cluster1: { id: 1 }, + cluster2: { id: 2 }, + draft: { id: 3 }, + }; + + const mockState = { + api: { clusterMap }, + subscription: {}, + }; + + (useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => usePaywall()); + expect(result.current.canUseFeature(SaasFeatures.WorkloadClustersLimit)).toBe(true); + }); + }); +}); diff --git a/hooks/usePaywall.ts b/hooks/usePaywall.ts index 36fc6c9d..ad3fb62a 100644 --- a/hooks/usePaywall.ts +++ b/hooks/usePaywall.ts @@ -1,39 +1,46 @@ import { useMemo } from 'react'; import { useAppSelector } from '@/redux/store'; -import { ClusterType } from '@/types/provision'; -import { SaasPlans } from '@/types/subscription'; +import { SaasFeatures, SaasPlans } from '@/types/subscription'; -export const CLUSTERS_LIMIT: { [key: string]: number } = { +export const CLUSTERS_LIMIT_FALLBACK: { [key: string]: number } = { + [SaasPlans.Community]: 3, [SaasPlans.Pro]: 10, [SaasPlans.Enterprise]: Infinity, }; export default function usePaywall() { - const { license, plan } = useAppSelector(({ subscription }) => ({ + const { clusterMap, license, plan } = useAppSelector(({ api, subscription }) => ({ + clusterMap: api.clusterMap, license: subscription.license, plan: subscription.license?.plan?.name, })); const canUseFeature = (featureCode: string): boolean => { if (license?.plan && license?.is_active) { - if (featureCode === 'physicalClusters') { - const clusterLimit = CLUSTERS_LIMIT[plan as string]; + const feature = license.plan.features.find(({ code }) => code === featureCode); + + if (featureCode === SaasFeatures.WorkloadClustersLimit) { + const clusterLimit = feature?.data.limit || CLUSTERS_LIMIT_FALLBACK[plan as string]; return !!activeClusters && clusterLimit > activeClusters.length; } - return !!license.plan.features.find(({ code }) => code === featureCode); + return !!feature; + } + + if (!license?.licenseKey) { + return ( + Object.keys(clusterMap).filter((clusterKey) => clusterKey != 'draft').length < + CLUSTERS_LIMIT_FALLBACK[SaasPlans.Community] + ); } return false; }; const activeClusters = useMemo( - () => - license?.clusters?.filter( - ({ isActive, clusterType }) => isActive && clusterType === ClusterType.WORKLOAD, - ), + () => license?.clusters?.filter(({ isActive }) => isActive), [license?.clusters], ); diff --git a/redux/selectors/subscription.selector.ts b/redux/selectors/subscription.selector.ts index 348f90e3..9669e28e 100644 --- a/redux/selectors/subscription.selector.ts +++ b/redux/selectors/subscription.selector.ts @@ -22,9 +22,17 @@ export const selectPendingInvoice = () => export const selectUpgradeLicenseDefinition = () => createSelector(subscriptionSelector, ({ license }) => { + if (!license?.licenseKey) { + return { + text: 'You’ve reached the workload clusters limit.', + description: 'Upgrade to a Pro plan to provision the number of clusters you need.', + ctaText: 'Upgrade to a Pro plan', + }; + } + if (license?.plan?.name === SaasPlans.Pro) { return { - text: 'You’ve reached the 10 physical clusters limit.', + text: 'You’ve reached the workload clusters limit.', description: 'Upgrade to an Enterprise plan to provision the number of clusters you need.', ctaText: 'Contact us to upgrade', }; diff --git a/types/config/index.ts b/types/config/index.ts index e6fc6d38..6836b63b 100644 --- a/types/config/index.ts +++ b/types/config/index.ts @@ -9,7 +9,7 @@ export enum FeatureFlag { CLUSTER_MANAGEMENT = 'cluster-managament', MARKETPLACE = 'marketplace', SHOW_CLOUDFLARE_CA_ISSUER = 'showCloudflareCaIssuerField', - SAAS_SUBSCRIPTION = 'saas-subscription', + SAAS_SUBSCRIPTION = 'saas-subscription-v2', } export interface EnvironmentVariables { diff --git a/types/subscription/index.ts b/types/subscription/index.ts index b84112a4..0832ef89 100644 --- a/types/subscription/index.ts +++ b/types/subscription/index.ts @@ -4,6 +4,10 @@ export enum SaasPlans { Enterprise = 'Enterprise', } +export enum SaasFeatures { + WorkloadClustersLimit = 'workloadClustersLimit', +} + export enum LicenseStatus { Active = 'active', UpToDate = 'up-todate', @@ -34,11 +38,16 @@ export interface Plan { licenses: License[]; } +export interface ClusterLimitPlan { + limit: number; +} export interface PlanFeatures { id: string; code: string; name: string; + data: ClusterLimitPlan; plan: Plan; + isActive: boolean; } export interface Cluster {