diff --git a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx index 1f5845f4a6b..63e7975900a 100644 --- a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx +++ b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx @@ -37,6 +37,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) if ( selectedType === ApplicationType.MachineToMachine && isDevFeaturesEnabled && + hasMachineToMachineAppsReachedLimit && planId === ReservedPlanId.Pro ) { return ( diff --git a/packages/console/src/components/ApplicationCreation/CreateForm/index.tsx b/packages/console/src/components/ApplicationCreation/CreateForm/index.tsx index 097c7cf7010..b3fcb617869 100644 --- a/packages/console/src/components/ApplicationCreation/CreateForm/index.tsx +++ b/packages/console/src/components/ApplicationCreation/CreateForm/index.tsx @@ -49,6 +49,7 @@ function CreateForm({ }: Props) { const { handleSubmit, + watch, control, register, formState: { errors, isSubmitting }, @@ -122,7 +123,10 @@ function CreateForm({ title="applications.create" subtitle={subtitleElement} paywall={conditional( - isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro + isDevFeaturesEnabled && + watch('type') === ApplicationType.MachineToMachine && + planId === ReservedPlanId.Pro && + ReservedPlanId.Pro )} size={defaultCreateType ? 'medium' : 'large'} footer={ diff --git a/packages/console/src/components/PlanUsage/ProPlanUsageCard/index.tsx b/packages/console/src/components/PlanUsage/ProPlanUsageCard/index.tsx index 70b60b8876e..31403dded65 100644 --- a/packages/console/src/components/PlanUsage/ProPlanUsageCard/index.tsx +++ b/packages/console/src/components/PlanUsage/ProPlanUsageCard/index.tsx @@ -19,10 +19,19 @@ export type Props = { readonly usageKey: AdminConsoleKey; readonly titleKey: AdminConsoleKey; readonly tooltipKey: AdminConsoleKey; + readonly unitPrice: number; readonly className?: string; }; -function ProPlanUsageCard({ usage, quota, usageKey, titleKey, tooltipKey, className }: Props) { +function ProPlanUsageCard({ + usage, + quota, + unitPrice, + usageKey, + titleKey, + tooltipKey, + className, +}: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); return ( @@ -38,7 +47,9 @@ function ProPlanUsageCard({ usage, quota, usageKey, titleKey, tooltipKey, classN a: , }} > - {t(tooltipKey)} + {t(tooltipKey, { + price: unitPrice, + })} } > diff --git a/packages/console/src/components/PlanUsage/index.tsx b/packages/console/src/components/PlanUsage/index.tsx index c48e06c0274..640bd96a0b3 100644 --- a/packages/console/src/components/PlanUsage/index.tsx +++ b/packages/console/src/components/PlanUsage/index.tsx @@ -14,7 +14,7 @@ import { formatPeriod } from '@/utils/subscription'; import ProPlanUsageCard, { type Props as ProPlanUsageCardProps } from './ProPlanUsageCard'; import styles from './index.module.scss'; -import { usageKeys, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils'; +import { usageKeys, usageKeyPriceMap, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils'; type Props = { /** @deprecated */ @@ -67,6 +67,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi usageKey: `subscription.usage.${usageKeyMap[key]}`, titleKey: `subscription.usage.${titleKeyMap[key]}`, tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`, + unitPrice: usageKeyPriceMap[key], ...cond( key === 'tokenLimit' && currentSubscriptionQuota.tokenLimit && { quota: currentSubscriptionQuota.tokenLimit } diff --git a/packages/console/src/components/PlanUsage/utils.ts b/packages/console/src/components/PlanUsage/utils.ts index 4234148079c..39edb7ae25c 100644 --- a/packages/console/src/components/PlanUsage/utils.ts +++ b/packages/console/src/components/PlanUsage/utils.ts @@ -1,6 +1,16 @@ import { type TFuncKey } from 'i18next'; import { type NewSubscriptionQuota } from '@/cloud/types/router'; +import { + resourceAddOnUnitPrice, + machineToMachineAddOnUnitPrice, + tenantMembersAddOnUnitPrice, + mfaAddOnUnitPrice, + enterpriseSsoAddOnUnitPrice, + organizationAddOnUnitPrice, + tokenAddOnUnitPrice, + hooksAddOnUnitPrice, +} from '@/consts/subscriptions'; type UsageKey = Pick< NewSubscriptionQuota, @@ -15,6 +25,7 @@ type UsageKey = Pick< | 'hooksLimit' >; +// We decide not to show `hooksLimit` usage in console for now. export const usageKeys: Array = [ 'mauLimit', 'organizationsEnabled', @@ -24,9 +35,20 @@ export const usageKeys: Array = [ 'machineToMachineLimit', 'tenantMembersLimit', 'tokenLimit', - 'hooksLimit', ]; +export const usageKeyPriceMap: Record = { + mauLimit: 0, + organizationsEnabled: organizationAddOnUnitPrice, + mfaEnabled: mfaAddOnUnitPrice, + enterpriseSsoLimit: enterpriseSsoAddOnUnitPrice, + resourcesLimit: resourceAddOnUnitPrice, + machineToMachineLimit: machineToMachineAddOnUnitPrice, + tenantMembersLimit: tenantMembersAddOnUnitPrice, + tokenLimit: tokenAddOnUnitPrice, + hooksLimit: hooksAddOnUnitPrice, +}; + export const usageKeyMap: Record< keyof UsageKey, TFuncKey<'translation', 'admin_console.subscription.usage'> diff --git a/packages/console/src/consts/subscriptions.ts b/packages/console/src/consts/subscriptions.ts index b61278333b8..5062e336305 100644 --- a/packages/console/src/consts/subscriptions.ts +++ b/packages/console/src/consts/subscriptions.ts @@ -18,6 +18,8 @@ export const tenantMembersAddOnUnitPrice = 8; export const mfaAddOnUnitPrice = 48; export const enterpriseSsoAddOnUnitPrice = 48; export const organizationAddOnUnitPrice = 48; +export const tokenAddOnUnitPrice = 80; +export const hooksAddOnUnitPrice = 2; /* === Add-on unit price (in USD) === */ /** diff --git a/packages/console/src/hooks/use-subscribe.ts b/packages/console/src/hooks/use-subscribe.ts index b2d611d69e0..50bf3df7628 100644 --- a/packages/console/src/hooks/use-subscribe.ts +++ b/packages/console/src/hooks/use-subscribe.ts @@ -103,14 +103,15 @@ const useSubscribe = () => { // Should not use hard-coded plan update here, need to update the tenant's subscription data with response from corresponding API. if (isDevFeaturesEnabled) { - const { id, ...rest } = await cloudApi.get('/api/tenants/:tenantId/subscription', { + const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId, }, }); mutateSubscriptionQuotaAndUsages(); - onCurrentSubscriptionUpdated(); + onCurrentSubscriptionUpdated(subscription); + const { id, ...rest } = subscription; updateTenant(tenantId, { planId: rest.planId, diff --git a/packages/console/src/onboarding/pages/CreateTenant/index.tsx b/packages/console/src/onboarding/pages/CreateTenant/index.tsx index 02b4a3584c8..9d591ccaba3 100644 --- a/packages/console/src/onboarding/pages/CreateTenant/index.tsx +++ b/packages/console/src/onboarding/pages/CreateTenant/index.tsx @@ -81,14 +81,13 @@ function CreateTenant() { if (collaboratorEmails.length > 0) { // Should not block the onboarding flow if the invitation fails. try { - await Promise.all( - collaboratorEmails.map(async (email) => - tenantCloudApi.post('/api/tenants/:tenantId/invitations', { - params: { tenantId: newTenant.id }, - body: { invitee: email.value, roleName: TenantRole.Collaborator }, - }) - ) - ); + await tenantCloudApi.post('/api/tenants/:tenantId/invitations', { + params: { tenantId: newTenant.id }, + body: { + invitee: collaboratorEmails.map(({ value }) => value), + roleName: TenantRole.Collaborator, + }, + }); toast.success(t('tenant_members.messages.invitation_sent')); } catch { toast.error(t('tenants.create_modal.invitation_failed', { duration: 5 })); diff --git a/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx b/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx index 7c8b0e787f8..3dc2ddba28a 100644 --- a/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx +++ b/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx @@ -53,7 +53,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) { ); } - if (isDevFeaturesEnabled && planId === ReservedPlanId.Pro) { + if (isDevFeaturesEnabled && hasReachedLimit && planId === ReservedPlanId.Pro) { return ( (); + mutateSubscriptionQuotaAndUsages(); reset(convertMfaConfigToForm(updatedMfaConfig)); toast.success(t('general.saved')); onMfaUpdated(updatedMfaConfig); diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx index 81b48c30a5b..aa590afaf1e 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx @@ -1,4 +1,4 @@ -import { cond, conditional } from '@silverhand/essentials'; +import { cond } from '@silverhand/essentials'; import { useContext, useMemo } from 'react'; import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; @@ -28,9 +28,9 @@ type Props = { }; function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodicUsage }: Props) { - const { currentTenant } = useContext(TenantsContext); const { currentSku, currentSubscription, currentSubscriptionQuota } = useContext(SubscriptionDataContext); + const { currentTenant } = useContext(TenantsContext); const { id, name, @@ -40,7 +40,7 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi const periodicUsage = useMemo( () => rawPeriodicUsage ?? - conditional( + cond( currentTenant && { mauLimit: currentTenant.usage.activeUsers, tokenLimit: currentTenant.usage.tokenUsage, @@ -50,11 +50,12 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi ); /** - * After the new pricing model goes live, `upcomingInvoice` will always exist. However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0. + * After the new pricing model goes live, `upcomingInvoice` will always exist. `upcomingInvoice` is updated more frequently than `currentSubscription.upcomingInvoice`. + * However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0. */ const upcomingCost = useMemo( () => currentSubscription.upcomingInvoice?.subtotal ?? currentSku.unitPrice ?? 0, - [currentSku.unitPrice, currentSubscription.upcomingInvoice?.subtotal] + [currentSku.unitPrice, currentSubscription.upcomingInvoice] ); if (!periodicUsage) { diff --git a/packages/console/src/pages/TenantSettings/Subscription/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/index.tsx index 4d90ba9a62f..21f8acab2b6 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/index.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useEffect } from 'react'; import useSWR from 'swr'; import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; @@ -39,6 +39,12 @@ function Subscription() { }) ); + useEffect(() => { + if (isCloud && isDevFeaturesEnabled) { + onCurrentSubscriptionUpdated(); + } + }, [onCurrentSubscriptionUpdated]); + if (isLoading) { return ; } diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx index 07c69f1c6ca..3438db34ebe 100644 --- a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx @@ -18,6 +18,7 @@ import Select, { type Option } from '@/ds-components/Select'; import TextLink from '@/ds-components/TextLink'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import modalStyles from '@/scss/modal.module.scss'; +import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; import InviteEmailsInput from '../InviteEmailsInput'; import useEmailInputUtils from '../InviteEmailsInput/hooks'; @@ -41,6 +42,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) { const { show } = useConfirmModal(); const { currentSubscription: { planId }, + currentSubscriptionQuota, + currentSubscriptionUsage: { tenantMembersLimit }, } = useContext(SubscriptionDataContext); const formMethods = useForm({ @@ -72,6 +75,12 @@ function InviteMemberModal({ isOpen, onClose }: Props) { [t] ); + const hasTenantMembersReachedLimit = hasReachedSubscriptionQuotaLimit({ + quotaKey: 'tenantMembersLimit', + usage: tenantMembersLimit, + quota: currentSubscriptionQuota, + }); + const onSubmit = handleSubmit(async ({ emails, role }) => { if (role === TenantRole.Admin) { const [result] = await show({ @@ -89,19 +98,17 @@ function InviteMemberModal({ isOpen, onClose }: Props) { } setIsLoading(true); - try { - await Promise.all( - emails.map(async (email) => - cloudApi.post('/api/tenants/:tenantId/invitations', { - params: { tenantId: currentTenantId }, - body: { invitee: email.value, roleName: role }, - }) - ) - ); - toast.success(t('tenant_members.messages.invitation_sent')); - onClose(true); - } finally { - setIsLoading(false); + if (emails.length > 0) { + try { + await cloudApi.post('/api/tenants/:tenantId/invitations', { + params: { tenantId: currentTenantId }, + body: { invitee: emails.map(({ value }) => value), roleName: role }, + }); + toast.success(t('tenant_members.messages.invitation_sent')); + onClose(true); + } finally { + setIsLoading(false); + } } }); @@ -123,24 +130,26 @@ function InviteMemberModal({ isOpen, onClose }: Props) { subtitle="tenant_members.invite_modal.subtitle" footer={ conditional( - isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ( - - , - a: , - }} + isDevFeaturesEnabled && + hasTenantMembersReachedLimit && + planId === ReservedPlanId.Pro && ( + - {t('upsell.add_on.footer.tenant_members', { - price: tenantMembersAddOnUnitPrice, - })} - - - ) + , + a: , + }} + > + {t('upsell.add_on.footer.tenant_members', { + price: tenantMembersAddOnUnitPrice, + })} + + + ) ) ?? (