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:
parent
78cee52079
commit
e41e0c99e3
7 changed files with 80 additions and 37 deletions
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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),
|
||||
[]
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Add table
Reference in a new issue