diff --git a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx index c3ffd6561..2c57e37c3 100644 --- a/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx +++ b/packages/console/src/components/ApplicationCreation/CreateForm/Footer/index.tsx @@ -6,10 +6,11 @@ import AddOnNoticeFooter from '@/components/AddOnNoticeFooter'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import SkuName from '@/components/SkuName'; +import { contactEmailLink } from '@/consts'; import { addOnPricingExplanationLink } from '@/consts/external-links'; import { machineToMachineAddOnUnitPrice } from '@/consts/subscriptions'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; -import Button from '@/ds-components/Button'; +import Button, { LinkButton } from '@/ds-components/Button'; import TextLink from '@/ds-components/TextLink'; import useApplicationsUsage from '@/hooks/use-applications-usage'; import useUserPreferences from '@/hooks/use-user-preferences'; @@ -35,6 +36,8 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) hasAppsReachedLimit, hasMachineToMachineAppsReachedLimit, hasThirdPartyAppsReachedLimit, + hasSamlAppsReachedLimit, + hasSamlAppsSurpassedLimit, } = useApplicationsUsage(); const { data: { m2mUpsellNoticeAcknowledged }, @@ -91,6 +94,17 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) ); } + if (selectedType === ApplicationType.SAML && hasSamlAppsReachedLimit) { + return ( + + ); + } + // Third party app is only available for paid plan (pro plan). if (isThirdParty && hasThirdPartyAppsReachedLimit) { return ( diff --git a/packages/console/src/components/FeatureTag/index.tsx b/packages/console/src/components/FeatureTag/index.tsx index f1f5f835c..c323e575a 100644 --- a/packages/console/src/components/FeatureTag/index.tsx +++ b/packages/console/src/components/FeatureTag/index.tsx @@ -12,15 +12,16 @@ import styles from './index.module.scss'; export { default as BetaTag } from './BetaTag'; /** - * The display tag mapping for each ReservedPlanId. + * The display tag mapping for each plan type. */ -const planIdTagMap: Record = { +const planTagMap = { [ReservedPlanId.Free]: 'free', [ReservedPlanId.Pro]: 'pro', [ReservedPlanId.Pro202411]: 'pro', [ReservedPlanId.Development]: 'dev', [ReservedPlanId.Admin]: 'admin', -}; + enterprise: 'enterprise', +} as const; /** * The minimum plan required to use the feature. @@ -35,14 +36,24 @@ export type Props = { * tenants. */ readonly isVisible: boolean; + readonly className?: string; + /** + * When set to true, the feature is considered as an enterprise feature, + * and the plan field becomes optional as enterprise features are not tied to specific plans. + */ + readonly isEnterprise?: boolean; +} & ( + | { readonly isEnterprise: true } /** * The minimum plan required to use the feature. * Currently we only have pro plan paywall. * Set the default value to the latest pro plan id we are using. + * + * Note: This field is required when isEnterprise is false or undefined, + * and optional when isEnterprise is true. */ - readonly plan: PaywallPlanId; - readonly className?: string; -}; + | { readonly isEnterprise?: false; readonly plan: PaywallPlanId } +); /** * A tag that indicates whether a feature requires a paid plan. @@ -72,10 +83,10 @@ export type Props = { * ``` */ function FeatureTag(props: Props) { - const { className } = props; + const { className, isEnterprise } = props; const { isDevTenant } = useContext(TenantsContext); - const { isVisible, plan } = props; + const { isVisible } = props; // Dev tenant should always see the tag since they have access to almost all features, and it's // useful for developers to know which features need to be paid for in production. @@ -83,7 +94,11 @@ function FeatureTag(props: Props) { return null; } - return
{planIdTagMap[plan]}
; + if (isEnterprise) { + return
{planTagMap.enterprise}
; + } + + return
{planTagMap[props.plan]}
; } export default FeatureTag; @@ -92,7 +107,7 @@ type CombinedAddOnAndFeatureTagProps = { readonly hasAddOnTag?: boolean; readonly className?: string; /** The minimum plan required to use the feature. */ - readonly paywall?: Props['plan']; + readonly paywall?: PaywallPlanId; }; /** diff --git a/packages/console/src/components/Guide/GuideCard/index.tsx b/packages/console/src/components/Guide/GuideCard/index.tsx index e4a8a0945..c91a08386 100644 --- a/packages/console/src/components/Guide/GuideCard/index.tsx +++ b/packages/console/src/components/Guide/GuideCard/index.tsx @@ -1,10 +1,11 @@ -import { Theme } from '@logto/schemas'; +import { ApplicationType, Theme } from '@logto/schemas'; import classNames from 'classnames'; -import { Suspense, useCallback } from 'react'; +import { Suspense, useCallback, useContext } from 'react'; import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types'; import FeatureTag, { BetaTag } from '@/components/FeatureTag'; import { latestProPlanId } from '@/consts/subscriptions'; +import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import Button from '@/ds-components/Button'; import useTheme from '@/hooks/use-theme'; import { onKeyDownHandler } from '@/utils/a11y'; @@ -27,6 +28,9 @@ type Props = { function GuideCard({ data, onClick, hasBorder, hasButton, hasPaywall, isBeta }: Props) { const { id, Logo, DarkLogo, metadata } = data; + const { + currentSubscription: { isEnterprisePlan }, + } = useContext(SubscriptionDataContext); const { target, name, description } = metadata; const buttonText = target === 'API' ? 'guide.get_started' : 'guide.start_building'; @@ -62,7 +66,14 @@ function GuideCard({ data, onClick, hasBorder, hasButton, hasPaywall, isBeta }:
{name}
{hasTags && (
- {hasPaywall && } + {hasPaywall && + (target === ApplicationType.SAML ? ( + isEnterprisePlan ? null : ( + + ) + ) : ( + + ))} {isBeta && }
)} diff --git a/packages/console/src/components/Guide/GuideCardGroup/index.tsx b/packages/console/src/components/Guide/GuideCardGroup/index.tsx index 99c7f90c2..c160197ae 100644 --- a/packages/console/src/components/Guide/GuideCardGroup/index.tsx +++ b/packages/console/src/components/Guide/GuideCardGroup/index.tsx @@ -1,3 +1,4 @@ +import { ApplicationType } from '@logto/schemas'; import classNames from 'classnames'; import { type Ref, forwardRef, useContext } from 'react'; @@ -42,8 +43,11 @@ function GuideCardGroup( data={guide} hasPaywall={ isCloud && - guide.metadata.isThirdParty && - (currentSubscriptionQuota.thirdPartyApplicationsLimit === 0 || isDevTenant) + ((guide.metadata.isThirdParty && + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (currentSubscriptionQuota.thirdPartyApplicationsLimit === 0 || isDevTenant)) || + (guide.metadata.target === ApplicationType.SAML && + (currentSubscriptionQuota.samlApplicationsLimit === 0 || isDevTenant))) } onClick={onClickGuide} /> diff --git a/packages/console/src/components/Guide/hooks.ts b/packages/console/src/components/Guide/hooks.ts index 44bb2d2ea..af62c1414 100644 --- a/packages/console/src/components/Guide/hooks.ts +++ b/packages/console/src/components/Guide/hooks.ts @@ -1,10 +1,9 @@ import { ApplicationType } from '@logto/schemas'; -import { useCallback, useMemo, useContext } from 'react'; +import { useCallback, useMemo } from 'react'; import { guides } from '@/assets/docs/guides'; import { type Guide } from '@/assets/docs/guides/types'; import { isCloud as isCloudEnv, isDevFeaturesEnabled } from '@/consts/env'; -import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { thirdPartyAppCategory, type AppGuideCategory, @@ -36,8 +35,6 @@ export const useAppGuideMetadata = (): { filters?: FilterOptions ) => Record; } => { - const { currentSubscriptionQuota } = useContext(SubscriptionDataContext); - const appGuides = useMemo( () => guides @@ -47,14 +44,9 @@ export const useAppGuideMetadata = (): { /** * Show SAML guides when it is: * 1. Cloud env - * 2. `quota.samlApplicationsLimit` is not 0. */ ) - .filter( - ({ metadata: { target } }) => - target !== ApplicationType.SAML || - (isCloudEnv && currentSubscriptionQuota.samlApplicationsLimit !== 0) - ), + .filter(({ metadata: { target } }) => target !== ApplicationType.SAML || isCloudEnv), [] ); diff --git a/packages/console/src/hooks/use-applications-usage.ts b/packages/console/src/hooks/use-applications-usage.ts index 4344216a7..648bc9ef8 100644 --- a/packages/console/src/hooks/use-applications-usage.ts +++ b/packages/console/src/hooks/use-applications-usage.ts @@ -18,11 +18,17 @@ const useApplicationsUsage = () => { const hasAppsReachedLimit = hasReachedSubscriptionQuotaLimit('applicationsLimit'); + const hasSamlAppsReachedLimit = hasReachedSubscriptionQuotaLimit('samlApplicationsLimit'); + + const hasSamlAppsSurpassedLimit = hasSurpassedSubscriptionQuotaLimit('samlApplicationsLimit'); + return { hasMachineToMachineAppsReachedLimit, hasMachineToMachineAppsSurpassedLimit, hasAppsReachedLimit, hasThirdPartyAppsReachedLimit, + hasSamlAppsReachedLimit, + hasSamlAppsSurpassedLimit, }; }; diff --git a/packages/console/src/pages/Applications/components/GuideLibrary/index.tsx b/packages/console/src/pages/Applications/components/GuideLibrary/index.tsx index a76bf8f0a..66012f40c 100644 --- a/packages/console/src/pages/Applications/components/GuideLibrary/index.tsx +++ b/packages/console/src/pages/Applications/components/GuideLibrary/index.tsx @@ -10,7 +10,7 @@ import FeatureTag from '@/components/FeatureTag'; import { type SelectedGuide } from '@/components/Guide/GuideCard'; import GuideCardGroup from '@/components/Guide/GuideCardGroup'; import { useAppGuideMetadata } from '@/components/Guide/hooks'; -import { isDevFeaturesEnabled, isCloud } from '@/consts/env'; +import { isCloud } from '@/consts/env'; import { latestProPlanId } from '@/consts/subscriptions'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { CheckboxGroup } from '@/ds-components/Checkbox'; @@ -36,7 +36,10 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide } const [filterCategories, setFilterCategories] = useState([]); const { getFilteredAppGuideMetadata, getStructuredAppGuideMetadata } = useAppGuideMetadata(); const isApplicationCreateModal = pathname.includes('/applications/create'); - const { currentSubscriptionQuota } = useContext(SubscriptionDataContext); + const { + currentSubscriptionQuota, + currentSubscription: { isEnterprisePlan }, + } = useContext(SubscriptionDataContext); const structuredMetadata = useMemo( () => getStructuredAppGuideMetadata({ categories: filterCategories }), @@ -96,16 +99,8 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide } /** * Show SAML guides when it is: * 1. Cloud env - * 2. `isDevFeatureEnabled` is true - * 3. `quota.samlApplicationsLimit` is not 0. */ - .filter( - (category) => - category !== 'SAML' || - (isCloud && - isDevFeaturesEnabled && - currentSubscriptionQuota.samlApplicationsLimit !== 0) - ) + .filter((category) => category !== 'SAML' || isCloud) .filter((category) => isCloud || category !== 'Protected') .map((category) => ({ title: `guide.categories.${category}`, @@ -123,6 +118,12 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide } ), } ), + ...cond( + isCloud && + category === 'SAML' && { + tag: , + } + ), }))} value={filterCategories} onChange={(value) => {