0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor: add enterprise tag for SAML apps (#6986)

This commit is contained in:
Darcy Ye 2025-01-27 17:22:53 +08:00 committed by GitHub
parent 78cee52079
commit e41e0c99e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 80 additions and 37 deletions

View file

@ -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 (
<LinkButton
size="large"
type="primary"
title="general.contact_us_action"
href={contactEmailLink}
/>
);
}
// Third party app is only available for paid plan (pro plan).
if (isThirdParty && hasThirdPartyAppsReachedLimit) {
return (

View file

@ -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<ReservedPlanId, string> = {
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 <div className={classNames(styles.tag, className)}>{planIdTagMap[plan]}</div>;
if (isEnterprise) {
return <div className={classNames(styles.tag, className)}>{planTagMap.enterprise}</div>;
}
return <div className={classNames(styles.tag, className)}>{planTagMap[props.plan]}</div>;
}
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;
};
/**

View file

@ -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 }:
<div className={styles.name}>{name}</div>
{hasTags && (
<div className={styles.tagWrapper}>
{hasPaywall && <FeatureTag isVisible plan={latestProPlanId} />}
{hasPaywall &&
(target === ApplicationType.SAML ? (
isEnterprisePlan ? null : (
<FeatureTag isVisible isEnterprise />
)
) : (
<FeatureTag isVisible plan={latestProPlanId} />
))}
{isBeta && <BetaTag />}
</div>
)}

View file

@ -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}
/>

View file

@ -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<AppGuideCategory, readonly Guide[]>;
} => {
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),
[]
);

View file

@ -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,
};
};

View file

@ -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<AppGuideCategory[]>([]);
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: <FeatureTag isEnterprise isVisible={!isEnterprisePlan} />,
}
),
}))}
value={filterCategories}
onChange={(value) => {