= {
+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) => {