diff --git a/packages/console/package.json b/packages/console/package.json index a93c6f63e17d..5422519c1dfb 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@fontsource/roboto-mono": "^5.0.0", "@jest/types": "^29.5.0", - "@logto/cloud": "0.2.5-a7eedce", + "@logto/cloud": "0.2.5-3b703da", "@logto/connector-kit": "workspace:^4.0.0", "@logto/core-kit": "workspace:^2.5.0", "@logto/elements": "workspace:^0.0.0", diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts index ed175b7afda6..5a368733a591 100644 --- a/packages/console/src/cloud/types/router.ts +++ b/packages/console/src/cloud/types/router.ts @@ -11,10 +11,30 @@ export type SubscriptionPlanResponse = GuardedResponse< GetRoutes['/api/subscription-plans'] >[number]; +export type LogtoSkuResponse = GetArrayElementType>; + export type Subscription = GuardedResponse; +/** @deprecated */ export type SubscriptionUsage = GuardedResponse; +/** ===== Use `New` in the naming to avoid confusion with legacy types ===== */ +/** The response of `GET /api/tenants/my/subscription/quota` has the same response type. */ +export type NewSubscriptionQuota = GuardedResponse< + GetRoutes['/api/tenants/:tenantId/subscription/quota'] +>; + +/** The response of `GET /api/tenants/my/subscription/usage` has the same response type. */ +export type NewSubscriptionUsage = GuardedResponse< + GetRoutes['/api/tenants/:tenantId/subscription/usage'] +>; + +/** The response of `GET /api/tenants/my/subscription/usage/:entityName/scopes` has the same response type. */ +export type NewSubscriptionScopeUsage = GuardedResponse< + GetRoutes['/api/tenants/:tenantId/subscription/usage/:entityName/scopes'] +>; +/** ===== Use `New` in the naming to avoid confusion with legacy types ===== */ + export type InvoicesResponse = GuardedResponse; export type InvitationResponse = GuardedResponse; diff --git a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx index 0928e6cf9805..4518190f9c40 100644 --- a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx +++ b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx @@ -5,6 +5,7 @@ import { Trans, useTranslation } from 'react-i18next'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import PlanName from '@/components/PlanName'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import useApplicationsUsage from '@/hooks/use-applications-usage'; @@ -17,7 +18,7 @@ type Props = { }; function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) { - const { currentPlan } = useContext(SubscriptionDataContext); + const { currentPlan, currentSku } = useContext(SubscriptionDataContext); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' }); const { hasAppsReachedLimit, @@ -32,7 +33,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) selectedType === ApplicationType.MachineToMachine && hasMachineToMachineAppsReachedLimit && // For paid plan (pro plan), we don't guard the m2m app creation since it's an add-on feature. - planId === ReservedPlanId.Free + (isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Free ) { return ( diff --git a/packages/console/src/components/ChargeNotification/index.tsx b/packages/console/src/components/ChargeNotification/index.tsx index 2fd62a5f4dfb..e2ce6ffe3ce0 100644 --- a/packages/console/src/components/ChargeNotification/index.tsx +++ b/packages/console/src/components/ChargeNotification/index.tsx @@ -5,6 +5,7 @@ import { useContext } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { newPlansBlogLink } from '@/consts'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import InlineNotification from '@/ds-components/InlineNotification'; import TextLink from '@/ds-components/TextLink'; @@ -38,7 +39,7 @@ function ChargeNotification({ checkedFlagKey, }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' }); - const { currentPlan } = useContext(SubscriptionDataContext); + const { currentPlan, currentSku } = useContext(SubscriptionDataContext); const { configs, updateConfigs } = useConfigs(); // Display null when loading @@ -52,7 +53,7 @@ function ChargeNotification({ Boolean(checkedChargeNotification?.[checkedFlagKey]) || !hasSurpassedLimit || // No charge notification for free plan - currentPlan.id === ReservedPlanId.Free + (isDevFeaturesEnabled ? currentSku.id : currentPlan.id) === ReservedPlanId.Free ) { return null; } diff --git a/packages/console/src/components/CreateConnectorForm/Footer/index.tsx b/packages/console/src/components/CreateConnectorForm/Footer/index.tsx index 53c7b4032cb0..8010ede10744 100644 --- a/packages/console/src/components/CreateConnectorForm/Footer/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/Footer/index.tsx @@ -4,16 +4,18 @@ import { type ConnectorFactoryResponse, ReservedPlanId, } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { useContext, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import PlanName from '@/components/PlanName'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import { type ConnectorGroup } from '@/types/connector'; -import { hasReachedQuotaLimit } from '@/utils/quota'; +import { hasReachedQuotaLimit, hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; type Props = { readonly isCreatingSocialConnector: boolean; @@ -31,35 +33,51 @@ function Footer({ onClickCreateButton, }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' }); - const { currentPlan } = useContext(SubscriptionDataContext); + const { currentPlan, currentSku, currentSubscriptionUsage, currentSubscriptionQuota } = + useContext(SubscriptionDataContext); const standardConnectorCount = useMemo( () => - existingConnectors.filter( - ({ isStandard, isDemo, type }) => isStandard && !isDemo && type === ConnectorType.Social - ).length, + isDevFeaturesEnabled + ? // No more standard connector limit in new pricing model. + 0 + : existingConnectors.filter( + ({ isStandard, isDemo, type }) => isStandard && !isDemo && type === ConnectorType.Social + ).length, [existingConnectors] ); const socialConnectorCount = useMemo( () => - existingConnectors.filter( - ({ isStandard, isDemo, type }) => !isStandard && !isDemo && type === ConnectorType.Social - ).length, - [existingConnectors] + isDevFeaturesEnabled + ? currentSubscriptionUsage.socialConnectorsLimit + : existingConnectors.filter( + ({ isStandard, isDemo, type }) => + !isStandard && !isDemo && type === ConnectorType.Social + ).length, + [existingConnectors, currentSubscriptionUsage.socialConnectorsLimit] ); - const isStandardConnectorsReachLimit = hasReachedQuotaLimit({ - quotaKey: 'standardConnectorsLimit', - plan: currentPlan, - usage: standardConnectorCount, - }); + const isStandardConnectorsReachLimit = isDevFeaturesEnabled + ? // No more standard connector limit in new pricing model. + false + : hasReachedQuotaLimit({ + quotaKey: 'standardConnectorsLimit', + plan: currentPlan, + usage: standardConnectorCount, + }); - const isSocialConnectorsReachLimit = hasReachedQuotaLimit({ - quotaKey: 'socialConnectorsLimit', - plan: currentPlan, - usage: socialConnectorCount, - }); + const isSocialConnectorsReachLimit = isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'socialConnectorsLimit', + usage: currentSubscriptionUsage.socialConnectorsLimit, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'socialConnectorsLimit', + plan: currentPlan, + usage: socialConnectorCount, + }); if (isCreatingSocialConnector && selectedConnectorGroup) { const { id: planId, name: planName, quota } = currentPlan; @@ -70,13 +88,19 @@ function Footer({ , - planName: , + planName: ( + + ), }} > {quota.standardConnectorsLimit === 0 ? t('standard_connectors_feature') : t( - planId === ReservedPlanId.Pro ? 'standard_connectors_pro' : 'standard_connectors', + (isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Pro + ? 'standard_connectors_pro' + : 'standard_connectors', { count: quota.standardConnectorsLimit ?? 0, } @@ -92,11 +116,18 @@ function Footer({ , - planName: , + planName: ( + + ), }} > {t('social_connectors', { - count: quota.socialConnectorsLimit ?? 0, + count: + (isDevFeaturesEnabled + ? currentSubscriptionQuota.socialConnectorsLimit + : quota.socialConnectorsLimit) ?? 0, })} diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.module.scss b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.module.scss new file mode 100644 index 000000000000..b66971b01453 --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.module.scss @@ -0,0 +1,33 @@ +@use '@/scss/underscore' as _; + +.list { + flex: 1; + margin-block: 0; + padding-inline: 0; + padding-bottom: _.unit(8); + list-style: none; + + > li { + display: flex; + font: var(--font-body-2); + align-items: center; + gap: _.unit(2); + + .icon { + width: 16px; + height: 16px; + + &.failed { + color: var(--color-on-error-container); + } + + &.success { + color: var(--color-on-success-container); + } + } + + &:not(:first-child) { + margin-top: _.unit(3); + } + } +} diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.tsx new file mode 100644 index 000000000000..55a0ae4c7d99 --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/index.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames'; + +import Failed from '@/assets/icons/failed.svg'; +import Success from '@/assets/icons/success.svg'; + +import * as styles from './index.module.scss'; +import useFeaturedSkuContent from './use-featured-sku-content'; + +type Props = { + readonly skuId: string; +}; + +function FeaturedSkuContent({ skuId }: Props) { + const contentData = useFeaturedSkuContent(skuId); + + return ( +
    + {contentData.map(({ title, isAvailable }) => { + return ( +
  • + {isAvailable ? ( + + ) : ( + + )} + {title} +
  • + ); + })} +
+ ); +} + +export default FeaturedSkuContent; diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts new file mode 100644 index 000000000000..05ff7ab88e7b --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts @@ -0,0 +1,77 @@ +import { ReservedPlanId } from '@logto/schemas'; +import { cond } from '@silverhand/essentials'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + freePlanAuditLogsRetentionDays, + freePlanM2mLimit, + freePlanMauLimit, + freePlanPermissionsLimit, + freePlanRoleLimit, + proPlanAuditLogsRetentionDays, +} from '@/consts/subscriptions'; + +type ContentData = { + title: string; + isAvailable: boolean; +}; + +const useFeaturedSkuContent = (skuId: string) => { + const { t } = useTranslation(undefined, { + keyPrefix: 'admin_console.upsell.featured_plan_content', + }); + + const contentData: ContentData[] = useMemo(() => { + const isFreePlan = skuId === ReservedPlanId.Free; + const planPhraseKey = isFreePlan ? 'free_plan' : 'pro_plan'; + + return [ + { + title: t(`mau.${planPhraseKey}`, { ...cond(isFreePlan && { count: freePlanMauLimit }) }), + isAvailable: true, + }, + { + title: t(`m2m.${planPhraseKey}`, { ...cond(isFreePlan && { count: freePlanM2mLimit }) }), + isAvailable: true, + }, + { + title: t('third_party_apps'), + isAvailable: !isFreePlan, + }, + { + title: t('mfa'), + isAvailable: !isFreePlan, + }, + { + title: t('sso'), + isAvailable: !isFreePlan, + }, + { + title: t(`role_and_permissions.${planPhraseKey}`, { + ...cond( + isFreePlan && { + roleCount: freePlanRoleLimit, + permissionCount: freePlanPermissionsLimit, + } + ), + }), + isAvailable: true, + }, + { + title: t('organizations'), + isAvailable: !isFreePlan, + }, + { + title: t('audit_logs', { + count: isFreePlan ? freePlanAuditLogsRetentionDays : proPlanAuditLogsRetentionDays, + }), + isAvailable: true, + }, + ]; + }, [t, skuId]); + + return contentData; +}; + +export default useFeaturedSkuContent; diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.module.scss b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.module.scss new file mode 100644 index 000000000000..816d8c7282be --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.module.scss @@ -0,0 +1,103 @@ +@use '@/scss/underscore' as _; + +.container { + position: relative; + flex: 1; + border-radius: 12px; + border: 1px solid var(--color-divider); + display: flex; + flex-direction: column; +} + +.planInfo { + padding: _.unit(6); + border-bottom: 1px solid var(--color-divider); + + > div:not(:first-child) { + margin-top: _.unit(4); + } + + .title { + font: var(--font-headline-2); + } + + .priceInfo { + > div:not(:first-child) { + margin-top: _.unit(1); + } + + .priceLabel { + font: var(--font-body-3); + color: var(--color-text-secondary); + } + + .price { + font: var(--font-headline-3); + } + } + + .description { + margin-top: _.unit(1); + font: var(--font-body-2); + color: var(--color-text-secondary); + height: 40px; + } +} + +.content { + flex: 1; + padding: _.unit(6); + display: flex; + flex-direction: column; + + .tip { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: _.unit(4); + + &.exceedFreeTenantsTip { + font: var(--font-body-2); + color: var(--color-text-secondary); + } + + .link { + font: var(--font-label-2); + display: flex; + align-items: center; + } + + .linkIcon { + width: 16px; + height: 16px; + } + } + + .list { + flex: 1; + padding-bottom: _.unit(8); + } +} + +.mostPopularTag { + position: absolute; + border-radius: 4px 4px 0; + font: var(--font-label-3); + padding: _.unit(1.5) _.unit(2) _.unit(1.5) _.unit(2.5); + color: var(--color-white); + background-color: var(--color-specific-tag-upsell); + right: -5px; + top: _.unit(6); + width: 64px; + text-align: center; + + &::after { + display: block; + content: ''; + position: absolute; + right: 0; + bottom: -3px; + border-left: 4px solid var(--color-primary-60); + border-bottom: 3px solid transparent; + } +} diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.tsx new file mode 100644 index 000000000000..7d3a5817a3ee --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/SkuCardItem/index.tsx @@ -0,0 +1,99 @@ +import { maxFreeTenantLimit, adminTenantId, ReservedPlanId } from '@logto/schemas'; +import classNames from 'classnames'; +import { useContext, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import ArrowRight from '@/assets/icons/arrow-right.svg'; +import { type LogtoSkuResponse } from '@/cloud/types/router'; +import PlanDescription from '@/components/PlanDescription'; +import PlanName from '@/components/PlanName'; +import { pricingLink } from '@/consts'; +import { TenantsContext } from '@/contexts/TenantsProvider'; +import Button from '@/ds-components/Button'; +import DangerousRaw from '@/ds-components/DangerousRaw'; +import DynamicT from '@/ds-components/DynamicT'; +import TextLink from '@/ds-components/TextLink'; + +import FeaturedSkuContent from './FeaturedSkuContent'; +import * as styles from './index.module.scss'; + +type Props = { + readonly sku: LogtoSkuResponse; + readonly onSelect: () => void; + readonly buttonProps?: Partial>; +}; + +function SkuCardItem({ sku, onSelect, buttonProps }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.create_tenant' }); + const { tenants } = useContext(TenantsContext); + const { unitPrice: basePrice, id: skuId } = sku; + + const isFreeSku = skuId === ReservedPlanId.Free; + + const isFreeTenantExceeded = useMemo( + () => + /** Should not block admin tenant owners from creating more than three tenants */ + !tenants.some(({ id }) => id === adminTenantId) && + tenants.filter(({ planId }) => planId === ReservedPlanId.Free).length >= maxFreeTenantLimit, + [tenants] + ); + + return ( +
+
+
+ +
+
+
{t('base_price')}
+
+ ${t('monthly_price', { value: (basePrice ?? 0) / 100 })} +
+
+
+ +
+
+
+ + {isFreeSku && isFreeTenantExceeded && ( +
+ {t('free_tenants_limit', { count: maxFreeTenantLimit })} +
+ )} + {!isFreeSku && ( +
+ } + className={styles.link} + > + + +
+ )} +
+ {skuId === ReservedPlanId.Pro && ( +
{t('most_popular')}
+ )} +
+ ); +} + +export default SkuCardItem; diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.module.scss b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.module.scss new file mode 100644 index 000000000000..8699b3095373 --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.module.scss @@ -0,0 +1,9 @@ +@use '@/scss/underscore' as _; + +.container { + display: flex; + justify-content: space-between; + align-items: stretch; + gap: _.unit(7); +} + diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.tsx new file mode 100644 index 000000000000..d3a4493e105d --- /dev/null +++ b/packages/console/src/components/CreateTenantModal/SelectTenantBasicSkuModal/index.tsx @@ -0,0 +1,104 @@ +import { ReservedPlanId } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Modal from 'react-modal'; + +import { useCloudApi, toastResponseError } from '@/cloud/hooks/use-cloud-api'; +import { type TenantResponse, type LogtoSkuResponse } from '@/cloud/types/router'; +import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils'; +import { pricingLink } from '@/consts'; +import DangerousRaw from '@/ds-components/DangerousRaw'; +import ModalLayout from '@/ds-components/ModalLayout'; +import TextLink from '@/ds-components/TextLink'; +import useLogtoSkus from '@/hooks/use-logto-skus'; +import useSubscribe from '@/hooks/use-subscribe'; +import * as modalStyles from '@/scss/modal.module.scss'; +import { pickupFeaturedLogtoSkus } from '@/utils/subscription'; + +import { type CreateTenantData } from '../types'; + +import SkuCardItem from './SkuCardItem'; +import * as styles from './index.module.scss'; + +type Props = { + readonly tenantData?: CreateTenantData; + readonly onClose: (tenant?: TenantResponse) => void; +}; + +function SelectTenantBasicSkuModal({ tenantData, onClose }: Props) { + const [isSubmitting, setIsSubmitting] = useState(); + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { data: logtoSkus } = useLogtoSkus(); + const { subscribe } = useSubscribe(); + const cloudApi = useCloudApi({ hideErrorToast: true }); + const reservedBasicLogtoSkus = conditional(logtoSkus && pickupFeaturedLogtoSkus(logtoSkus)); + + if (!reservedBasicLogtoSkus || !tenantData) { + return null; + } + + const handleSelectSku = async (logtoSku: LogtoSkuResponse) => { + const { id: skuId } = logtoSku; + try { + setIsSubmitting(skuId); + if (skuId === ReservedPlanId.Free) { + const { name, tag, regionName } = tenantData; + const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag, regionName } }); + + reportToGoogle(GtagConversionId.CreateProductionTenant, { transactionId: newTenant.id }); + onClose(newTenant); + return; + } + + await subscribe({ skuId, planId: skuId, tenantData }); + } catch (error: unknown) { + void toastResponseError(error); + } finally { + setIsSubmitting(undefined); + } + }; + + return ( + { + onClose(); + }} + > + + }}> + {t('upsell.create_tenant.description')} + + + } + size="large" + onClose={onClose} + > +
+ {reservedBasicLogtoSkus.map((logtoSku) => ( + { + void handleSelectSku(logtoSku); + }} + /> + ))} +
+
+
+ ); +} + +export default SelectTenantBasicSkuModal; diff --git a/packages/console/src/components/CreateTenantModal/index.tsx b/packages/console/src/components/CreateTenantModal/index.tsx index d6525cc5162f..0387fa2f531f 100644 --- a/packages/console/src/components/CreateTenantModal/index.tsx +++ b/packages/console/src/components/CreateTenantModal/index.tsx @@ -10,6 +10,7 @@ import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg'; import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { type TenantResponse } from '@/cloud/types/router'; import Region, { RegionName } from '@/components/Region'; +import { isDevFeaturesEnabled } from '@/consts/env'; import Button from '@/ds-components/Button'; import DangerousRaw from '@/ds-components/DangerousRaw'; import FormField from '@/ds-components/FormField'; @@ -20,6 +21,7 @@ import useTheme from '@/hooks/use-theme'; import * as modalStyles from '@/scss/modal.module.scss'; import EnvTagOptionContent from './EnvTagOptionContent'; +import SelectTenantBasicSkuModal from './SelectTenantBasicSkuModal'; import SelectTenantPlanModal from './SelectTenantPlanModal'; import * as styles from './index.module.scss'; import { type CreateTenantData } from './types'; @@ -161,19 +163,35 @@ function CreateTenantModal({ isOpen, onClose }: Props) { /> - { - setTenantData(undefined); - if (tenant) { - /** - * Note: only close the create tenant modal when tenant is created successfully - */ - onClose(tenant); - toast.success(t('tenants.create_modal.tenant_created')); - } - }} - /> + {isDevFeaturesEnabled ? ( + { + setTenantData(undefined); + if (tenant) { + /** + * Note: only close the create tenant modal when tenant is created successfully + */ + onClose(tenant); + toast.success(t('tenants.create_modal.tenant_created')); + } + }} + /> + ) : ( + { + setTenantData(undefined); + if (tenant) { + /** + * Note: only close the create tenant modal when tenant is created successfully + */ + onClose(tenant); + toast.success(t('tenants.create_modal.tenant_created')); + } + }} + /> + )} ); diff --git a/packages/console/src/components/MauExceededModal/index.tsx b/packages/console/src/components/MauExceededModal/index.tsx index 3d44dddf6925..a2746d76eaa0 100644 --- a/packages/console/src/components/MauExceededModal/index.tsx +++ b/packages/console/src/components/MauExceededModal/index.tsx @@ -1,9 +1,11 @@ +import { conditional } from '@silverhand/essentials'; import { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; import PlanUsage from '@/components/PlanUsage'; import { contactEmailLink } from '@/consts'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { subscriptionPage } from '@/consts/pages'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { TenantsContext } from '@/contexts/TenantsProvider'; @@ -21,7 +23,13 @@ import * as styles from './index.module.scss'; function MauExceededModal() { const { currentTenant } = useContext(TenantsContext); const { usage } = currentTenant ?? {}; - const { currentPlan, currentSubscription } = useContext(SubscriptionDataContext); + const { + currentPlan, + currentSubscription, + currentSku, + currentSubscriptionQuota, + currentSubscriptionUsage, + } = useContext(SubscriptionDataContext); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { navigate } = useTenantPathname(); @@ -40,7 +48,10 @@ function MauExceededModal() { name: planName, } = currentPlan; - const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit; + const isMauExceeded = isDevFeaturesEnabled + ? currentSubscriptionQuota.mauLimit !== null && + currentSubscriptionUsage.mauLimit >= currentSubscriptionQuota.mauLimit + : mauLimit !== null && usage.activeUsers >= mauLimit; if (!isMauExceeded) { return null; @@ -76,7 +87,9 @@ function MauExceededModal() { , + planName: ( + + ), }} > {t('upsell.mau_exceeded_modal.notification')} diff --git a/packages/console/src/components/PlanDescription/index.tsx b/packages/console/src/components/PlanDescription/index.tsx index f39a94c1b664..1db3e078542e 100644 --- a/packages/console/src/components/PlanDescription/index.tsx +++ b/packages/console/src/components/PlanDescription/index.tsx @@ -1,4 +1,5 @@ import { ReservedPlanId } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { type TFuncKey } from 'i18next'; import DynamicT from '@/ds-components/DynamicT'; @@ -11,10 +12,17 @@ const registeredPlanDescriptionPhrasesMap: Record< [ReservedPlanId.Pro]: 'pro_plan_description', }; -type Props = { readonly planId: string }; +type Props = { + /** Temporarily mark as optional. */ + readonly skuId?: string; + /** @deprecated */ + readonly planId: string; +}; -function PlanDescription({ planId }: Props) { - const description = registeredPlanDescriptionPhrasesMap[planId]; +function PlanDescription({ skuId, planId }: Props) { + const description = + conditional(skuId && registeredPlanDescriptionPhrasesMap[skuId]) ?? + registeredPlanDescriptionPhrasesMap[planId]; if (!description) { return null; diff --git a/packages/console/src/components/PlanName/index.tsx b/packages/console/src/components/PlanName/index.tsx index 2dc5f8e77b48..abd91e3a02d2 100644 --- a/packages/console/src/components/PlanName/index.tsx +++ b/packages/console/src/components/PlanName/index.tsx @@ -1,3 +1,4 @@ +import { conditional } from '@silverhand/essentials'; import { type TFuncKey } from 'i18next'; import { useTranslation } from 'react-i18next'; @@ -15,12 +16,17 @@ const registeredPlanNamePhraseMap: Record< }; type Props = { + /** Temporarily use optional for backward compatibility. */ + readonly skuId?: string; + /** @deprecated */ readonly name: string; }; -function PlanName({ name }: Props) { +// TODO: rename the component once new pricing model is ready, should be `SkuName`. +function PlanName({ skuId, name }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' }); - const planNamePhrase = registeredPlanNamePhraseMap[name]; + const planNamePhrase = + conditional(skuId && registeredPlanNamePhraseMap[skuId]) ?? registeredPlanNamePhraseMap[name]; /** * Note: fallback to the plan name if the phrase is not registered. diff --git a/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx b/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx index 394c31d99c76..200cc7a536ca 100644 --- a/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx +++ b/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx @@ -1,15 +1,26 @@ -import { type TenantResponse } from '@/cloud/types/router'; +import { conditional } from '@silverhand/essentials'; + +import { + type TenantResponse, + type NewSubscriptionUsage, + type NewSubscriptionQuota, +} from '@/cloud/types/router'; +import { isDevFeaturesEnabled } from '@/consts/env'; import DynamicT from '@/ds-components/DynamicT'; import Tag from '@/ds-components/Tag'; import { type SubscriptionPlan } from '@/types/subscriptions'; type Props = { readonly tenantData: TenantResponse; - readonly tenantPlan: SubscriptionPlan; + readonly tenantSubscriptionPlan: SubscriptionPlan; + readonly tenantStatus?: { + usage: NewSubscriptionUsage; + quota: NewSubscriptionQuota; + }; readonly className?: string; }; -function TenantStatusTag({ tenantData, tenantPlan, className }: Props) { +function TenantStatusTag({ tenantData, tenantSubscriptionPlan, tenantStatus, className }: Props) { const { usage, openInvoices, isSuspended } = tenantData; /** @@ -35,15 +46,24 @@ function TenantStatusTag({ tenantData, tenantPlan, className }: Props) { ); } + const { usage: tenantUsage, quota: tenantQuota } = tenantStatus ?? {}; + const isNewPricingModelMauExceeded = conditional( + isDevFeaturesEnabled && + tenantUsage && + tenantQuota?.mauLimit !== null && + tenantQuota?.mauLimit && + tenantUsage.mauLimit >= tenantQuota.mauLimit + ); + const { activeUsers } = usage; const { quota: { mauLimit }, - } = tenantPlan; + } = tenantSubscriptionPlan; const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit; - if (isMauExceeded) { + if (isNewPricingModelMauExceeded ?? isMauExceeded) { return ( diff --git a/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx b/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx index 714dc97d39a2..9158f81602e4 100644 --- a/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx +++ b/packages/console/src/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx @@ -26,13 +26,17 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) { subscription: { planId }, } = tenantData; - const { subscriptionPlans } = useContext(SubscriptionDataContext); - const tenantPlan = useMemo( + const { + subscriptionPlans, + currentSubscriptionUsage: usage, + currentSubscriptionQuota: quota, + } = useContext(SubscriptionDataContext); + const tenantSubscriptionPlan = useMemo( () => subscriptionPlans.find((plan) => plan.id === planId), [subscriptionPlans, planId] ); - if (!tenantPlan) { + if (!tenantSubscriptionPlan) { return null; } @@ -44,7 +48,8 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) { @@ -52,7 +57,7 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) { {tag === TenantTag.Development ? ( ) : ( - + )} diff --git a/packages/console/src/consts/quota-item-phrases.ts b/packages/console/src/consts/quota-item-phrases.ts index fb26ee59104d..d977bf6e9e52 100644 --- a/packages/console/src/consts/quota-item-phrases.ts +++ b/packages/console/src/consts/quota-item-phrases.ts @@ -2,6 +2,7 @@ import { type TFuncKey } from 'i18next'; import { type SubscriptionPlanQuota } from '@/types/subscriptions'; +/** @deprecated */ export const quotaItemPhrasesMap: Record< keyof SubscriptionPlanQuota, TFuncKey<'translation', 'admin_console.subscription.quota_item'> @@ -30,6 +31,7 @@ export const quotaItemPhrasesMap: Record< customJwtEnabled: 'custom_jwt_enabled.name', }; +/** @deprecated */ export const quotaItemUnlimitedPhrasesMap: Record< keyof SubscriptionPlanQuota, TFuncKey<'translation', 'admin_console.subscription.quota_item'> @@ -58,6 +60,7 @@ export const quotaItemUnlimitedPhrasesMap: Record< customJwtEnabled: 'custom_jwt_enabled.unlimited', }; +/** @deprecated */ export const quotaItemLimitedPhrasesMap: Record< keyof SubscriptionPlanQuota, TFuncKey<'translation', 'admin_console.subscription.quota_item'> @@ -86,6 +89,7 @@ export const quotaItemLimitedPhrasesMap: Record< customJwtEnabled: 'custom_jwt_enabled.limited', }; +/** @deprecated */ export const quotaItemNotEligiblePhrasesMap: Record< keyof SubscriptionPlanQuota, TFuncKey<'translation', 'admin_console.subscription.quota_item'> diff --git a/packages/console/src/consts/tenants.ts b/packages/console/src/consts/tenants.ts index e3bfe16c81c8..4024716f2df8 100644 --- a/packages/console/src/consts/tenants.ts +++ b/packages/console/src/consts/tenants.ts @@ -1,8 +1,14 @@ import { ReservedPlanId, TenantTag, defaultManagementApi } from '@logto/schemas'; import dayjs from 'dayjs'; -import { type TenantResponse } from '@/cloud/types/router'; +import { + type NewSubscriptionQuota, + type LogtoSkuResponse, + type TenantResponse, + type NewSubscriptionUsage, +} from '@/cloud/types/router'; import { RegionName } from '@/components/Region'; +import { LogtoSkuType } from '@/types/skus'; import { type SubscriptionPlan } from '@/types/subscriptions'; import { adminEndpoint, isCloud } from './env'; @@ -71,9 +77,92 @@ export const defaultSubscriptionPlan: SubscriptionPlan = { thirdPartyApplicationsLimit: null, tenantMembersLimit: null, customJwtEnabled: true, + subjectTokenEnabled: true, + bringYourUiEnabled: true, }, }; +/** + * - For cloud, the initial tenant's subscription plan will be fetched from the cloud API. + * - OSS has a fixed subscription plan with `development` id and no cloud API to dynamically fetch the subscription plan. + */ +export const defaultLogtoSku: LogtoSkuResponse = { + id: ReservedPlanId.Development, + name: 'Logto Development plan', + createdAt: new Date(), + updatedAt: new Date(), + type: LogtoSkuType.Basic, + unitPrice: 0, + quota: { + // A soft limit for abuse monitoring + mauLimit: 100, + tokenLimit: null, + applicationsLimit: null, + machineToMachineLimit: null, + resourcesLimit: null, + scopesPerResourceLimit: null, + socialConnectorsLimit: null, + userRolesLimit: null, + machineToMachineRolesLimit: null, + scopesPerRoleLimit: null, + hooksLimit: null, + auditLogsRetentionDays: 14, + mfaEnabled: true, + organizationsEnabled: true, + enterpriseSsoLimit: null, + thirdPartyApplicationsLimit: null, + tenantMembersLimit: 20, + customJwtEnabled: true, + subjectTokenEnabled: true, + bringYourUiEnabled: true, + }, +}; + +export const defaultSubscriptionQuota: NewSubscriptionQuota = { + mauLimit: 50_000, + tokenLimit: 500_000, + applicationsLimit: 3, + machineToMachineLimit: 1, + resourcesLimit: 1, + scopesPerResourceLimit: 1, + socialConnectorsLimit: 3, + userRolesLimit: 1, + machineToMachineRolesLimit: 1, + scopesPerRoleLimit: 1, + hooksLimit: 1, + auditLogsRetentionDays: 3, + mfaEnabled: false, + organizationsEnabled: false, + enterpriseSsoLimit: 0, + thirdPartyApplicationsLimit: 0, + tenantMembersLimit: 1, + customJwtEnabled: false, + subjectTokenEnabled: false, + bringYourUiEnabled: false, +}; + +export const defaultSubscriptionUsage: NewSubscriptionUsage = { + mauLimit: 0, + tokenLimit: 0, + applicationsLimit: 0, + machineToMachineLimit: 0, + resourcesLimit: 0, + scopesPerResourceLimit: 0, + socialConnectorsLimit: 0, + userRolesLimit: 0, + machineToMachineRolesLimit: 0, + scopesPerRoleLimit: 0, + hooksLimit: 0, + mfaEnabled: false, + organizationsEnabled: false, + enterpriseSsoLimit: 0, + thirdPartyApplicationsLimit: 0, + tenantMembersLimit: 0, + customJwtEnabled: false, + subjectTokenEnabled: false, + bringYourUiEnabled: false, +}; + const getAdminTenantEndpoint = () => { // Allow endpoint override for dev or testing if (adminEndpoint) { diff --git a/packages/console/src/containers/AppContent/index.tsx b/packages/console/src/containers/AppContent/index.tsx index 57d738306dd4..15f85c1999d4 100644 --- a/packages/console/src/containers/AppContent/index.tsx +++ b/packages/console/src/containers/AppContent/index.tsx @@ -6,6 +6,7 @@ import AppLoading from '@/components/AppLoading'; import Topbar from '@/components/Topbar'; import { isCloud } from '@/consts/env'; import SubscriptionDataProvider from '@/contexts/SubscriptionDataProvider'; +import useNewSubscriptionData from '@/contexts/SubscriptionDataProvider/use-new-subscription-data'; import useSubscriptionData from '@/contexts/SubscriptionDataProvider/use-subscription-data'; import { TenantsContext } from '@/contexts/TenantsProvider'; import useScroll from '@/hooks/use-scroll'; @@ -24,18 +25,38 @@ export default function AppContent() { const { currentTenant } = useContext(TenantsContext); const isTenantSuspended = isCloud && currentTenant?.isSuspended; const { isLoading: isLoadingSubscriptionData, ...subscriptionDta } = useSubscriptionData(); + const { + isLoading: isLoadingNewSubscriptionData, + logtoSkus, + currentSku, + currentSubscriptionQuota, + currentSubscriptionUsage, + currentSubscriptionScopeResourceUsage, + currentSubscriptionScopeRoleUsage, + } = useNewSubscriptionData(); const scrollableContent = useRef(null); const { scrollTop } = useScroll(scrollableContent.current); - const isLoading = isLoadingPreference || isLoadingSubscriptionData; + const isLoading = + isLoadingPreference || isLoadingSubscriptionData || isLoadingNewSubscriptionData; if (isLoading || !currentTenant) { return ; } return ( - +
{isTenantSuspended && } diff --git a/packages/console/src/contexts/SubscriptionDataProvider/index.tsx b/packages/console/src/contexts/SubscriptionDataProvider/index.tsx index 85b874af71c6..40ca0f6f1e8c 100644 --- a/packages/console/src/contexts/SubscriptionDataProvider/index.tsx +++ b/packages/console/src/contexts/SubscriptionDataProvider/index.tsx @@ -1,12 +1,18 @@ import { noop } from '@silverhand/essentials'; import { createContext, type ReactNode } from 'react'; -import { defaultSubscriptionPlan, defaultTenantResponse } from '@/consts'; +import { + defaultSubscriptionPlan, + defaultLogtoSku, + defaultTenantResponse, + defaultSubscriptionQuota, + defaultSubscriptionUsage, +} from '@/consts'; // Used in the docs // eslint-disable-next-line unused-imports/no-unused-imports import TenantAccess from '@/containers/TenantAccess'; -import { type Context } from './types'; +import { type FullContext } from './types'; const defaultSubscription = defaultTenantResponse.subscription; @@ -14,15 +20,22 @@ const defaultSubscription = defaultTenantResponse.subscription; * This context provides the subscription plans and subscription data of the current tenant. * CAUTION: You should only use this data context under the {@link TenantAccess} component */ -export const SubscriptionDataContext = createContext({ +export const SubscriptionDataContext = createContext({ subscriptionPlans: [], currentPlan: defaultSubscriptionPlan, currentSubscription: defaultSubscription, onCurrentSubscriptionUpdated: noop, + logtoSkus: [], + currentSku: defaultLogtoSku, + // For new pricing model + currentSubscriptionQuota: defaultSubscriptionQuota, + currentSubscriptionUsage: defaultSubscriptionUsage, + currentSubscriptionScopeResourceUsage: {}, + currentSubscriptionScopeRoleUsage: {}, }); type Props = { - readonly subscriptionData: Context; + readonly subscriptionData: FullContext; readonly children: ReactNode; }; diff --git a/packages/console/src/contexts/SubscriptionDataProvider/types.ts b/packages/console/src/contexts/SubscriptionDataProvider/types.ts index 65be35ce84df..5bbc83528e3d 100644 --- a/packages/console/src/contexts/SubscriptionDataProvider/types.ts +++ b/packages/console/src/contexts/SubscriptionDataProvider/types.ts @@ -1,9 +1,31 @@ -import { type Subscription } from '@/cloud/types/router'; +import { + type LogtoSkuResponse, + type Subscription, + type NewSubscriptionQuota, + type NewSubscriptionUsage, + type NewSubscriptionScopeUsage, +} from '@/cloud/types/router'; import { type SubscriptionPlan } from '@/types/subscriptions'; export type Context = { + /** @deprecated */ subscriptionPlans: SubscriptionPlan[]; + /** @deprecated */ currentPlan: SubscriptionPlan; currentSubscription: Subscription; onCurrentSubscriptionUpdated: (subscription?: Subscription) => void; }; + +type NewSubscriptionSupplementContext = { + logtoSkus: LogtoSkuResponse[]; + currentSku: LogtoSkuResponse; + currentSubscriptionQuota: NewSubscriptionQuota; + currentSubscriptionUsage: NewSubscriptionUsage; + currentSubscriptionScopeResourceUsage: NewSubscriptionScopeUsage; + currentSubscriptionScopeRoleUsage: NewSubscriptionScopeUsage; +}; + +export type NewSubscriptionContext = Omit & + NewSubscriptionSupplementContext; + +export type FullContext = Context & NewSubscriptionSupplementContext; diff --git a/packages/console/src/contexts/SubscriptionDataProvider/use-new-subscription-data.ts b/packages/console/src/contexts/SubscriptionDataProvider/use-new-subscription-data.ts new file mode 100644 index 000000000000..5e7791007f8f --- /dev/null +++ b/packages/console/src/contexts/SubscriptionDataProvider/use-new-subscription-data.ts @@ -0,0 +1,64 @@ +import { cond, condString } from '@silverhand/essentials'; +import { useContext, useMemo } from 'react'; + +import { + defaultLogtoSku, + defaultTenantResponse, + defaultSubscriptionQuota, + defaultSubscriptionUsage, +} from '@/consts'; +import { isCloud } from '@/consts/env'; +import { TenantsContext } from '@/contexts/TenantsProvider'; +import useLogtoSkus from '@/hooks/use-logto-skus'; +import useNewSubscriptionQuota from '@/hooks/use-new-subscription-quota'; +import useNewSubscriptionScopeUsage from '@/hooks/use-new-subscription-scopes-usage'; +import useNewSubscriptionUsage from '@/hooks/use-new-subscription-usage'; + +import useSubscription from '../../hooks/use-subscription'; + +import { type NewSubscriptionContext } from './types'; + +const useNewSubscriptionData: () => NewSubscriptionContext & { isLoading: boolean } = () => { + const { currentTenant } = useContext(TenantsContext); + const { isLoading: isLogtoSkusLoading, data: fetchedLogtoSkus } = useLogtoSkus(); + const { + data: currentSubscription, + isLoading: isSubscriptionLoading, + mutate: mutateSubscription, + } = useSubscription(condString(currentTenant?.id)); + const { data: currentSubscriptionQuota, isLoading: isSubscriptionQuotaLoading } = + useNewSubscriptionQuota(condString(currentTenant?.id)); + const { data: currentSubscriptionUsage, isLoading: isSubscriptionUsageLoading } = + useNewSubscriptionUsage(condString(currentTenant?.id)); + const { + scopeResourceUsage: { data: scopeResourceUsage, isLoading: isScopePerResourceUsageLoading }, + scopeRoleUsage: { data: scopeRoleUsage, isLoading: isScopePerRoleUsageLoading }, + } = useNewSubscriptionScopeUsage(condString(currentTenant?.id)); + + const logtoSkus = useMemo(() => cond(isCloud && fetchedLogtoSkus) ?? [], [fetchedLogtoSkus]); + + const currentSku = useMemo( + () => logtoSkus.find((logtoSku) => logtoSku.id === currentTenant?.planId) ?? defaultLogtoSku, + [currentTenant?.planId, logtoSkus] + ); + + return { + isLoading: + isSubscriptionLoading || + isLogtoSkusLoading || + isSubscriptionQuotaLoading || + isSubscriptionUsageLoading || + isScopePerResourceUsageLoading || + isScopePerRoleUsageLoading, + logtoSkus, + currentSku, + currentSubscription: currentSubscription ?? defaultTenantResponse.subscription, + onCurrentSubscriptionUpdated: mutateSubscription, + currentSubscriptionQuota: currentSubscriptionQuota ?? defaultSubscriptionQuota, + currentSubscriptionUsage: currentSubscriptionUsage ?? defaultSubscriptionUsage, + currentSubscriptionScopeResourceUsage: scopeResourceUsage ?? {}, + currentSubscriptionScopeRoleUsage: scopeRoleUsage ?? {}, + }; +}; + +export default useNewSubscriptionData; diff --git a/packages/console/src/hooks/use-api-resources-usage.ts b/packages/console/src/hooks/use-api-resources-usage.ts index 331f329b9760..5adbc1fe3e8b 100644 --- a/packages/console/src/hooks/use-api-resources-usage.ts +++ b/packages/console/src/hooks/use-api-resources-usage.ts @@ -3,12 +3,18 @@ import { useContext, useMemo } from 'react'; import useSWR from 'swr'; import { type ApiResource } from '@/consts'; -import { isCloud } from '@/consts/env'; +import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; -import { hasReachedQuotaLimit, hasSurpassedQuotaLimit } from '@/utils/quota'; +import { + hasReachedQuotaLimit, + hasReachedSubscriptionQuotaLimit, + hasSurpassedQuotaLimit, + hasSurpassedSubscriptionQuotaLimit, +} from '@/utils/quota'; const useApiResourcesUsage = () => { - const { currentPlan } = useContext(SubscriptionDataContext); + const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } = + useContext(SubscriptionDataContext); /** * Note: we only need to fetch all resources when the user is in cloud environment. @@ -17,28 +23,43 @@ const useApiResourcesUsage = () => { const { data: allResources } = useSWR(isCloud && 'api/resources'); const resourceCount = useMemo( - () => allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0, - [allResources] + () => + isDevFeaturesEnabled + ? currentSubscriptionUsage.resourcesLimit + : allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0, + [allResources, currentSubscriptionUsage.resourcesLimit] ); const hasReachedLimit = useMemo( () => - hasReachedQuotaLimit({ - quotaKey: 'resourcesLimit', - plan: currentPlan, - usage: resourceCount, - }), - [currentPlan, resourceCount] + isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'resourcesLimit', + usage: currentSubscriptionUsage.resourcesLimit, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'resourcesLimit', + plan: currentPlan, + usage: resourceCount, + }), + [currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit] ); const hasSurpassedLimit = useMemo( () => - hasSurpassedQuotaLimit({ - quotaKey: 'resourcesLimit', - plan: currentPlan, - usage: resourceCount, - }), - [currentPlan, resourceCount] + isDevFeaturesEnabled + ? hasSurpassedSubscriptionQuotaLimit({ + quotaKey: 'resourcesLimit', + usage: currentSubscriptionUsage.resourcesLimit, + quota: currentSubscriptionQuota, + }) + : hasSurpassedQuotaLimit({ + quotaKey: 'resourcesLimit', + plan: currentPlan, + usage: resourceCount, + }), + [currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit] ); return { diff --git a/packages/console/src/hooks/use-applications-usage.ts b/packages/console/src/hooks/use-applications-usage.ts index 99f42183dfab..37900c38283d 100644 --- a/packages/console/src/hooks/use-applications-usage.ts +++ b/packages/console/src/hooks/use-applications-usage.ts @@ -2,12 +2,18 @@ import { type Application, ApplicationType } from '@logto/schemas'; import { useContext, useMemo } from 'react'; import useSWR from 'swr'; -import { isCloud } from '@/consts/env'; +import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; -import { hasReachedQuotaLimit, hasSurpassedQuotaLimit } from '@/utils/quota'; +import { + hasReachedQuotaLimit, + hasReachedSubscriptionQuotaLimit, + hasSurpassedQuotaLimit, + hasSurpassedSubscriptionQuotaLimit, +} from '@/utils/quota'; const useApplicationsUsage = () => { - const { currentPlan } = useContext(SubscriptionDataContext); + const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } = + useContext(SubscriptionDataContext); /** * Note: we only need to fetch all applications when the user is in cloud environment. @@ -17,53 +23,103 @@ const useApplicationsUsage = () => { const m2mAppCount = useMemo( () => - allApplications?.filter(({ type }) => type === ApplicationType.MachineToMachine).length ?? 0, - [allApplications] + isDevFeaturesEnabled + ? currentSubscriptionUsage.machineToMachineLimit + : allApplications?.filter(({ type }) => type === ApplicationType.MachineToMachine).length ?? + 0, + [allApplications, currentSubscriptionUsage.machineToMachineLimit] ); const thirdPartyAppCount = useMemo( - () => allApplications?.filter(({ isThirdParty }) => isThirdParty).length ?? 0, - [allApplications] + () => + isDevFeaturesEnabled + ? currentSubscriptionUsage.thirdPartyApplicationsLimit + : allApplications?.filter(({ isThirdParty }) => isThirdParty).length ?? 0, + [allApplications, currentSubscriptionUsage.thirdPartyApplicationsLimit] ); const hasMachineToMachineAppsReachedLimit = useMemo( () => - hasReachedQuotaLimit({ - quotaKey: 'machineToMachineLimit', - plan: currentPlan, - usage: m2mAppCount, - }), - [currentPlan, m2mAppCount] + isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'machineToMachineLimit', + usage: currentSubscriptionUsage.machineToMachineLimit, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'machineToMachineLimit', + plan: currentPlan, + usage: m2mAppCount, + }), + [ + currentPlan, + m2mAppCount, + currentSubscriptionUsage.machineToMachineLimit, + currentSubscriptionQuota, + ] ); const hasMachineToMachineAppsSurpassedLimit = useMemo( () => - hasSurpassedQuotaLimit({ - quotaKey: 'machineToMachineLimit', - plan: currentPlan, - usage: m2mAppCount, - }), - [currentPlan, m2mAppCount] + isDevFeaturesEnabled + ? hasSurpassedSubscriptionQuotaLimit({ + quotaKey: 'machineToMachineLimit', + usage: currentSubscriptionUsage.machineToMachineLimit, + quota: currentSubscriptionQuota, + }) + : hasSurpassedQuotaLimit({ + quotaKey: 'machineToMachineLimit', + plan: currentPlan, + usage: m2mAppCount, + }), + [ + currentPlan, + m2mAppCount, + currentSubscriptionUsage.machineToMachineLimit, + currentSubscriptionQuota, + ] ); const hasThirdPartyAppsReachedLimit = useMemo( () => - hasReachedQuotaLimit({ - quotaKey: 'thirdPartyApplicationsLimit', - plan: currentPlan, - usage: thirdPartyAppCount, - }), - [currentPlan, thirdPartyAppCount] + isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'thirdPartyApplicationsLimit', + usage: currentSubscriptionUsage.thirdPartyApplicationsLimit, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'thirdPartyApplicationsLimit', + plan: currentPlan, + usage: thirdPartyAppCount, + }), + [ + currentPlan, + thirdPartyAppCount, + currentSubscriptionUsage.thirdPartyApplicationsLimit, + currentSubscriptionQuota, + ] ); const hasAppsReachedLimit = useMemo( () => - hasReachedQuotaLimit({ - quotaKey: 'applicationsLimit', - plan: currentPlan, - usage: allApplications?.length ?? 0, - }), - [allApplications?.length, currentPlan] + isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'applicationsLimit', + usage: currentSubscriptionUsage.applicationsLimit, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'applicationsLimit', + plan: currentPlan, + usage: allApplications?.length ?? 0, + }), + [ + allApplications?.length, + currentPlan, + currentSubscriptionUsage.applicationsLimit, + currentSubscriptionQuota, + ] ); return { diff --git a/packages/console/src/hooks/use-logto-skus.ts b/packages/console/src/hooks/use-logto-skus.ts new file mode 100644 index 000000000000..211ce9b98397 --- /dev/null +++ b/packages/console/src/hooks/use-logto-skus.ts @@ -0,0 +1,52 @@ +import { type Optional } from '@silverhand/essentials'; +import { useMemo } from 'react'; +import useSWRImmutable from 'swr/immutable'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type LogtoSkuResponse } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; +import { featuredPlanIdOrder } from '@/consts/subscriptions'; +// Used in the docs +// eslint-disable-next-line unused-imports/no-unused-imports +import TenantAccess from '@/containers/TenantAccess'; +import { LogtoSkuType } from '@/types/skus'; +import { sortBy } from '@/utils/sort'; +import { addSupportQuota } from '@/utils/subscription'; + +/** + * Fetch Logto SKUs from the cloud API. + * Note: If you want to retrieve Logto SKUs under the {@link TenantAccess} component, use `SubscriptionDataContext` instead. + */ +const useLogtoSkus = () => { + const cloudApi = useCloudApi(); + + const useSwrResponse = useSWRImmutable( + isCloud && '/api/skus', + async () => + cloudApi.get('/api/skus', { + search: { type: LogtoSkuType.Basic }, + }) + ); + + const { data: logtoSkuResponse } = useSwrResponse; + + const logtoSkus: Optional = useMemo(() => { + if (!logtoSkuResponse) { + return; + } + + return logtoSkuResponse + .map((logtoSku) => addSupportQuota(logtoSku)) + .slice() + .sort(({ id: previousId }, { id: nextId }) => + sortBy(featuredPlanIdOrder)(previousId, nextId) + ); + }, [logtoSkuResponse]); + + return { + ...useSwrResponse, + data: logtoSkus, + }; +}; + +export default useLogtoSkus; diff --git a/packages/console/src/hooks/use-new-subscription-quota.ts b/packages/console/src/hooks/use-new-subscription-quota.ts new file mode 100644 index 000000000000..8e679983f39c --- /dev/null +++ b/packages/console/src/hooks/use-new-subscription-quota.ts @@ -0,0 +1,19 @@ +import useSWR from 'swr'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type NewSubscriptionQuota } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; + +const useNewSubscriptionQuota = (tenantId: string) => { + const cloudApi = useCloudApi(); + + return useSWR( + isCloud && `/api/tenants/${tenantId}/subscription/quota`, + async () => + cloudApi.get('/api/tenants/:tenantId/subscription/quota', { + params: { tenantId }, + }) + ); +}; + +export default useNewSubscriptionQuota; diff --git a/packages/console/src/hooks/use-new-subscription-scopes-usage.ts b/packages/console/src/hooks/use-new-subscription-scopes-usage.ts new file mode 100644 index 000000000000..011f5cbd34be --- /dev/null +++ b/packages/console/src/hooks/use-new-subscription-scopes-usage.ts @@ -0,0 +1,33 @@ +import useSWR from 'swr'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type NewSubscriptionScopeUsage } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; + +const useNewSubscriptionScopeUsage = (tenantId: string) => { + const cloudApi = useCloudApi(); + + const resourceEntityName = 'resources'; + const roleEntityName = 'roles'; + + return { + scopeResourceUsage: useSWR( + isCloud && `/api/tenants/${tenantId}/subscription/usage/${resourceEntityName}/scopes`, + async () => + cloudApi.get('/api/tenants/:tenantId/subscription/usage/:entityName/scopes', { + params: { tenantId, entityName: resourceEntityName }, + search: {}, + }) + ), + scopeRoleUsage: useSWR( + isCloud && `/api/tenants/${tenantId}/subscription/usage/${roleEntityName}/scopes`, + async () => + cloudApi.get('/api/tenants/:tenantId/subscription/usage/:entityName/scopes', { + params: { tenantId, entityName: roleEntityName }, + search: {}, + }) + ), + }; +}; + +export default useNewSubscriptionScopeUsage; diff --git a/packages/console/src/hooks/use-new-subscription-usage.ts b/packages/console/src/hooks/use-new-subscription-usage.ts new file mode 100644 index 000000000000..b0af93689011 --- /dev/null +++ b/packages/console/src/hooks/use-new-subscription-usage.ts @@ -0,0 +1,19 @@ +import useSWR from 'swr'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type NewSubscriptionUsage } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; + +const useNewSubscriptionUsage = (tenantId: string) => { + const cloudApi = useCloudApi(); + + return useSWR( + isCloud && `/api/tenants/${tenantId}/subscription/usage`, + async () => + cloudApi.get('/api/tenants/:tenantId/subscription/usage', { + params: { tenantId }, + }) + ); +}; + +export default useNewSubscriptionUsage; diff --git a/packages/console/src/hooks/use-subscribe.ts b/packages/console/src/hooks/use-subscribe.ts index 6bb49a252467..bf3332fab3d7 100644 --- a/packages/console/src/hooks/use-subscribe.ts +++ b/packages/console/src/hooks/use-subscribe.ts @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { toastResponseError, useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { type CreateTenantData } from '@/components/CreateTenantModal/types'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { checkoutStateQueryKey } from '@/consts/subscriptions'; import { GlobalRoute, TenantsContext } from '@/contexts/TenantsProvider'; import { createLocalCheckoutSession } from '@/utils/checkout'; @@ -15,6 +16,12 @@ import { dropLeadingSlash } from '@/utils/url'; import useTenantPathname from './use-tenant-pathname'; type SubscribeProps = { + /** + * @remarks + * Temporarily mark this as optional for backward compatibility, in new pricing model we should always provide `skuId`. + */ + skuId?: string; + /** @deprecated in new pricing model */ planId: string; callbackPage?: string; tenantId?: string; @@ -30,6 +37,7 @@ const useSubscribe = () => { const [isSubscribeLoading, setIsSubscribeLoading] = useState(false); const subscribe = async ({ + skuId, planId, callbackPage, tenantId, @@ -54,6 +62,7 @@ const useSubscribe = () => { try { const { redirectUri, sessionId } = await cloudApi.post('/api/checkout-session', { body: { + skuId, planId, successCallbackUrl, tenantId, @@ -88,6 +97,20 @@ 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', { + params: { + tenantId, + }, + }); + updateTenant(tenantId, { + planId: rest.planId, + subscription: rest, + }); + return; + } + /** * Note: need to update the tenant's subscription cache data, * since the cancel subscription flow will not redirect to the stripe payment page. diff --git a/packages/console/src/hooks/use-subscription-plans.ts b/packages/console/src/hooks/use-subscription-plans.ts index eff34f682765..1ae69bd16f75 100644 --- a/packages/console/src/hooks/use-subscription-plans.ts +++ b/packages/console/src/hooks/use-subscription-plans.ts @@ -14,6 +14,7 @@ import { sortBy } from '@/utils/sort'; import { addSupportQuotaToPlan } from '@/utils/subscription'; /** + * @deprecated * Fetch subscription plans from the cloud API. * Note: If you want to retrieve subscription plans under the {@link TenantAccess} component, use `SubscriptionDataContext` instead. */ diff --git a/packages/console/src/hooks/use-subscription-usage.ts b/packages/console/src/hooks/use-subscription-usage.ts index 0381ce6aebfa..728424f055b2 100644 --- a/packages/console/src/hooks/use-subscription-usage.ts +++ b/packages/console/src/hooks/use-subscription-usage.ts @@ -4,6 +4,7 @@ import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { type SubscriptionUsage } from '@/cloud/types/router'; import { isCloud } from '@/consts/env'; +/** @deprecated */ const useSubscriptionUsage = (tenantId: string) => { const cloudApi = useCloudApi(); diff --git a/packages/console/src/pages/ApiResourceDetails/ApiResourcePermissions/components/CreatePermissionModal/index.tsx b/packages/console/src/pages/ApiResourceDetails/ApiResourcePermissions/components/CreatePermissionModal/index.tsx index eb90aa05bc78..348d408a310f 100644 --- a/packages/console/src/pages/ApiResourceDetails/ApiResourcePermissions/components/CreatePermissionModal/index.tsx +++ b/packages/console/src/pages/ApiResourceDetails/ApiResourcePermissions/components/CreatePermissionModal/index.tsx @@ -1,5 +1,6 @@ import { noSpaceRegEx } from '@logto/core-kit'; import type { Scope, CreateScope } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { useContext } from 'react'; import { useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; @@ -8,6 +9,7 @@ import ReactModal from 'react-modal'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import PlanName from '@/components/PlanName'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import FormField from '@/ds-components/FormField'; @@ -16,10 +18,11 @@ import TextInput from '@/ds-components/TextInput'; import useApi from '@/hooks/use-api'; import * as modalStyles from '@/scss/modal.module.scss'; import { trySubmitSafe } from '@/utils/form'; -import { hasReachedQuotaLimit } from '@/utils/quota'; +import { hasReachedQuotaLimit, hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; type Props = { readonly resourceId: string; + /** @deprecated get usage from cloud API after migrating to new pricing model */ readonly totalResourceCount: number; readonly onClose: (scope?: Scope) => void; }; @@ -27,7 +30,12 @@ type Props = { type CreatePermissionFormData = Pick; function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Props) { - const { currentPlan } = useContext(SubscriptionDataContext); + const { + currentPlan, + currentSku, + currentSubscriptionQuota, + currentSubscriptionScopeResourceUsage, + } = useContext(SubscriptionDataContext); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { @@ -52,11 +60,17 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop }) ); - const isScopesPerResourceReachLimit = hasReachedQuotaLimit({ - quotaKey: 'scopesPerResourceLimit', - plan: currentPlan, - usage: totalResourceCount, - }); + const isScopesPerResourceReachLimit = isDevFeaturesEnabled + ? hasReachedSubscriptionQuotaLimit({ + quotaKey: 'scopesPerResourceLimit', + usage: currentSubscriptionScopeResourceUsage[resourceId] ?? 0, + quota: currentSubscriptionQuota, + }) + : hasReachedQuotaLimit({ + quotaKey: 'scopesPerResourceLimit', + plan: currentPlan, + usage: totalResourceCount, + }); return ( , - planName: , + planName: ( + + ), }} > {t('upsell.paywall.scopes_per_resource', { - count: currentPlan.quota.scopesPerResourceLimit ?? 0, + count: + (isDevFeaturesEnabled + ? currentSubscriptionQuota.scopesPerResourceLimit + : currentPlan.quota.scopesPerResourceLimit) ?? 0, })} diff --git a/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx b/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx index 3ac6a3ea8b18..e08e30a97fd7 100644 --- a/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx +++ b/packages/console/src/pages/ApiResources/components/CreateForm/Footer.tsx @@ -5,6 +5,7 @@ import { Trans, useTranslation } from 'react-i18next'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import PlanName from '@/components/PlanName'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import useApiResourcesUsage from '@/hooks/use-api-resources-usage'; @@ -16,7 +17,11 @@ type Props = { function Footer({ isCreationLoading, onClickCreate }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { currentPlan } = useContext(SubscriptionDataContext); + const { + currentPlan, + currentSubscriptionUsage: { resourcesLimit }, + currentSku, + } = useContext(SubscriptionDataContext); const { hasReachedLimit } = useApiResourcesUsage(); if ( @@ -24,7 +29,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) { /** * We don't guard API resources quota limit for paid plan, since it's an add-on feature */ - currentPlan.id === ReservedPlanId.Free + (isDevFeaturesEnabled ? currentSku.id : currentPlan.id) === ReservedPlanId.Free ) { return ( @@ -35,7 +40,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) { }} > {t('upsell.paywall.resources', { - count: currentPlan.quota.resourcesLimit ?? 0, + count: (isDevFeaturesEnabled ? resourcesLimit : currentPlan.quota.resourcesLimit) ?? 0, })} diff --git a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx index a6f65db2049a..0334ee681723 100644 --- a/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx +++ b/packages/console/src/pages/Applications/components/ProtectedAppForm/index.tsx @@ -13,7 +13,7 @@ import useSWRImmutable from 'swr/immutable'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import PlanName from '@/components/PlanName'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; -import { isCloud } from '@/consts/env'; +import { isDevFeaturesEnabled, isCloud } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button, { type Props as ButtonProps } from '@/ds-components/Button'; import FormField from '@/ds-components/FormField'; @@ -36,6 +36,8 @@ type Props = { readonly onCreateSuccess?: (createdApp: Application) => void; }; +// TODO: refactor this component to reduce complexity +// eslint-disable-next-line complexity function ProtectedAppForm({ className, buttonAlignment = 'right', @@ -46,9 +48,12 @@ function ProtectedAppForm({ onCreateSuccess, }: Props) { const { data } = useSWRImmutable(isCloud && 'api/systems/application'); - const { currentPlan } = useContext(SubscriptionDataContext); + const { + currentPlan: { name: planName, quota }, + currentSku, + currentSubscriptionQuota, + } = useContext(SubscriptionDataContext); const { hasAppsReachedLimit } = useApplicationsUsage(); - const { name: planName, quota } = currentPlan; const defaultDomain = data?.protectedApps.defaultDomain ?? ''; const { navigate } = useTenantPathname(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -203,10 +208,17 @@ function ProtectedAppForm({ , - planName: , + planName: ( + + ), }} > - {t('upsell.paywall.applications', { count: quota.applicationsLimit ?? 0 })} + {t('upsell.paywall.applications', { + count: + (isDevFeaturesEnabled + ? currentSubscriptionQuota.applicationsLimit + : quota.applicationsLimit) ?? 0, + })} ) : ( diff --git a/packages/console/src/pages/CustomizeJwt/CreateButton/index.tsx b/packages/console/src/pages/CustomizeJwt/CreateButton/index.tsx index fe95b0c5b942..5c91a51ab186 100644 --- a/packages/console/src/pages/CustomizeJwt/CreateButton/index.tsx +++ b/packages/console/src/pages/CustomizeJwt/CreateButton/index.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; @@ -19,13 +20,14 @@ function CreateButton({ tokenType }: Props) { const { show } = useConfirmModal(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { currentPlan } = useContext(SubscriptionDataContext); - const { - quota: { customJwtEnabled }, - } = currentPlan; + const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext); + + const isCustomJwtEnabled = isDevFeaturesEnabled + ? currentSubscriptionQuota.customJwtEnabled + : currentPlan.quota.customJwtEnabled; const onCreateButtonClick = useCallback(async () => { - if (customJwtEnabled) { + if (isCustomJwtEnabled) { navigate(link); return; } @@ -50,7 +52,7 @@ function CreateButton({ tokenType }: Props) { // Navigate to subscription page by default navigate('/tenant-settings/subscription'); } - }, [customJwtEnabled, link, navigate, show, t]); + }, [isCustomJwtEnabled, link, navigate, show, t]); return (