mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(console): refactor console paywall guards (#6863)
* refactor(console): refactor console paywall guards refactor console paywall guards * refactor(console): refactor the paywall logic refactor the paywall logic * fix(console): replace hardcode pro plan id replace hardcoded pro plan id * chore(console): add some comments add some comments
This commit is contained in:
parent
788270b5a7
commit
69986bc179
25 changed files with 124 additions and 88 deletions
|
@ -1,6 +1,6 @@
|
||||||
import { type AdminConsoleKey } from '@logto/phrases';
|
import { type AdminConsoleKey } from '@logto/phrases';
|
||||||
import type { Application } from '@logto/schemas';
|
import type { Application } from '@logto/schemas';
|
||||||
import { ApplicationType, ReservedPlanId } from '@logto/schemas';
|
import { ApplicationType } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { type ReactElement, useContext, useMemo } from 'react';
|
import { type ReactElement, useContext, useMemo } from 'react';
|
||||||
import { useController, useForm } from 'react-hook-form';
|
import { useController, useForm } from 'react-hook-form';
|
||||||
|
@ -11,6 +11,7 @@ import { useSWRConfig } from 'swr';
|
||||||
|
|
||||||
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
|
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
|
@ -142,10 +143,7 @@ function CreateForm({
|
||||||
title="applications.create"
|
title="applications.create"
|
||||||
subtitle={subtitleElement}
|
subtitle={subtitleElement}
|
||||||
paywall={conditional(
|
paywall={conditional(
|
||||||
isPaidTenant &&
|
!isPaidTenant && watch('type') === ApplicationType.MachineToMachine && latestProPlanId
|
||||||
watch('type') === ApplicationType.MachineToMachine &&
|
|
||||||
planId !== ReservedPlanId.Pro &&
|
|
||||||
ReservedPlanId.Pro
|
|
||||||
)}
|
)}
|
||||||
hasAddOnTag={
|
hasAddOnTag={
|
||||||
isPaidTenant &&
|
isPaidTenant &&
|
||||||
|
|
|
@ -14,6 +14,7 @@ import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import FlipOnRtl from '@/ds-components/FlipOnRtl';
|
import FlipOnRtl from '@/ds-components/FlipOnRtl';
|
||||||
import TextLink from '@/ds-components/TextLink';
|
import TextLink from '@/ds-components/TextLink';
|
||||||
|
import { isProPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import FeaturedSkuContent from './FeaturedSkuContent';
|
import FeaturedSkuContent from './FeaturedSkuContent';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
@ -92,9 +93,7 @@ function SkuCardItem({ sku, onSelect, buttonProps }: Props) {
|
||||||
disabled={(isFreeSku && isFreeTenantExceeded) || buttonProps?.disabled}
|
disabled={(isFreeSku && isFreeTenantExceeded) || buttonProps?.disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{skuId === ReservedPlanId.Pro && (
|
{isProPlan(skuId) && <div className={styles.mostPopularTag}>{t('most_popular')}</div>}
|
||||||
<div className={styles.mostPopularTag}>{t('most_popular')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,29 @@ import { useContext } from 'react';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
import { isProPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export { default as BetaTag } from './BetaTag';
|
export { default as BetaTag } from './BetaTag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display tag mapping for each ReservedPlanId.
|
||||||
|
*/
|
||||||
|
const planIdTagMap: Record<ReservedPlanId, string> = {
|
||||||
|
[ReservedPlanId.Free]: 'free',
|
||||||
|
[ReservedPlanId.Pro]: 'pro',
|
||||||
|
[ReservedPlanId.Pro202411]: 'pro',
|
||||||
|
[ReservedPlanId.Development]: 'dev',
|
||||||
|
[ReservedPlanId.Admin]: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimum plan required to use the feature.
|
||||||
|
* Currently we only have pro plan paywall.
|
||||||
|
*/
|
||||||
|
export type PaywallPlanId = Extract<ReservedPlanId, ReservedPlanId.Pro | ReservedPlanId.Pro202411>;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
/**
|
/**
|
||||||
* Whether the tag should be visible. It should be `true` if the tenant's subscription
|
* Whether the tag should be visible. It should be `true` if the tenant's subscription
|
||||||
|
@ -17,8 +35,12 @@ export type Props = {
|
||||||
* tenants.
|
* tenants.
|
||||||
*/
|
*/
|
||||||
readonly isVisible: boolean;
|
readonly isVisible: boolean;
|
||||||
/** The minimum plan required to use the feature. */
|
/**
|
||||||
readonly plan: Exclude<ReservedPlanId, ReservedPlanId.Free | ReservedPlanId.Development>;
|
* 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.
|
||||||
|
*/
|
||||||
|
readonly plan: PaywallPlanId;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -61,7 +83,7 @@ function FeatureTag(props: Props) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={classNames(styles.tag, className)}>{plan}</div>;
|
return <div className={classNames(styles.tag, className)}>{planIdTagMap[plan]}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeatureTag;
|
export default FeatureTag;
|
||||||
|
@ -89,7 +111,7 @@ export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the "Add-on" tag for Pro plan.
|
// Show the "Add-on" tag for Pro plan.
|
||||||
if (hasAddOnTag && isCloud && planId === ReservedPlanId.Pro) {
|
if (hasAddOnTag && isCloud && isProPlan(planId)) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div>
|
<div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { ReservedPlanId, Theme } from '@logto/schemas';
|
import { Theme } from '@logto/schemas';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Suspense, useCallback } from 'react';
|
import { Suspense, useCallback } from 'react';
|
||||||
|
|
||||||
import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types';
|
import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types';
|
||||||
import FeatureTag, { BetaTag } from '@/components/FeatureTag';
|
import FeatureTag, { BetaTag } from '@/components/FeatureTag';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import useTheme from '@/hooks/use-theme';
|
import useTheme from '@/hooks/use-theme';
|
||||||
import { onKeyDownHandler } from '@/utils/a11y';
|
import { onKeyDownHandler } from '@/utils/a11y';
|
||||||
|
@ -61,7 +62,7 @@ function GuideCard({ data, onClick, hasBorder, hasButton, hasPaywall, isBeta }:
|
||||||
<div className={styles.name}>{name}</div>
|
<div className={styles.name}>{name}</div>
|
||||||
{hasTags && (
|
{hasTags && (
|
||||||
<div className={styles.tagWrapper}>
|
<div className={styles.tagWrapper}>
|
||||||
{hasPaywall && <FeatureTag isVisible plan={ReservedPlanId.Pro} />}
|
{hasPaywall && <FeatureTag isVisible plan={latestProPlanId} />}
|
||||||
{isBeta && <BetaTag />}
|
{isBeta && <BetaTag />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -9,6 +9,7 @@ const registeredPlanDescriptionPhrasesMap: Record<
|
||||||
> = {
|
> = {
|
||||||
[ReservedPlanId.Free]: 'free_plan_description',
|
[ReservedPlanId.Free]: 'free_plan_description',
|
||||||
[ReservedPlanId.Pro]: 'pro_plan_description',
|
[ReservedPlanId.Pro]: 'pro_plan_description',
|
||||||
|
[ReservedPlanId.Pro202411]: 'pro_plan_description',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRegisteredPlanDescriptionPhrase = (
|
const getRegisteredPlanDescriptionPhrase = (
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { formatPeriod, isPaidPlan } from '@/utils/subscription';
|
import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard';
|
import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
@ -134,7 +134,7 @@ function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
|
||||||
// Hide the quota notice for Pro plans if the basic quota is 0.
|
// Hide the quota notice for Pro plans if the basic quota is 0.
|
||||||
// Per current pricing model design, it should apply to `enterpriseSsoLimit`.
|
// Per current pricing model design, it should apply to `enterpriseSsoLimit`.
|
||||||
...cond(
|
...cond(
|
||||||
planId === ReservedPlanId.Pro &&
|
isProPlan(planId) &&
|
||||||
currentSubscriptionBasicQuota[key] === 0 && {
|
currentSubscriptionBasicQuota[key] === 0 && {
|
||||||
isQuotaNoticeHidden: true,
|
isQuotaNoticeHidden: true,
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { type LogtoSkuQuota } from '@/types/skus';
|
||||||
export const ticketSupportResponseTimeMap: Record<string, number> = {
|
export const ticketSupportResponseTimeMap: Record<string, number> = {
|
||||||
[ReservedPlanId.Free]: 0,
|
[ReservedPlanId.Free]: 0,
|
||||||
[ReservedPlanId.Pro]: 48,
|
[ReservedPlanId.Pro]: 48,
|
||||||
|
[ReservedPlanId.Pro202411]: 48,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
import { ReservedPlanId } from '@logto/schemas';
|
||||||
|
|
||||||
|
import { isDevFeaturesEnabled } from './env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared quota limits between the featured plan content in the `CreateTenantModal` and the `PlanComparisonTable`.
|
* Shared quota limits between the featured plan content in the `CreateTenantModal` and the `PlanComparisonTable`.
|
||||||
*/
|
*/
|
||||||
|
@ -34,3 +36,6 @@ export const featuredPlanIds: string[] = [ReservedPlanId.Free, ReservedPlanId.Pr
|
||||||
export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro];
|
export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro];
|
||||||
|
|
||||||
export const checkoutStateQueryKey = 'checkout-state';
|
export const checkoutStateQueryKey = 'checkout-state';
|
||||||
|
|
||||||
|
/** The latest pro plan id we are using. TODO: Remove this when we have the new Pro202411 plan released. */
|
||||||
|
export const latestProPlanId = isDevFeaturesEnabled ? ReservedPlanId.Pro202411 : ReservedPlanId.Pro;
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import type { AdminConsoleKey } from '@logto/phrases';
|
import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import { type ReservedPlanId } from '@logto/schemas';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { CombinedAddOnAndFeatureTag } from '@/components/FeatureTag';
|
import { CombinedAddOnAndFeatureTag, type PaywallPlanId } from '@/components/FeatureTag';
|
||||||
import type { Props as TextLinkProps } from '@/ds-components/TextLink';
|
import type { Props as TextLinkProps } from '@/ds-components/TextLink';
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
|
@ -22,10 +21,9 @@ export type Props = {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
/**
|
/**
|
||||||
* If a paywall tag should be shown next to the title. The value is the plan type.
|
* If a paywall tag should be shown next to the title. The value is the plan type.
|
||||||
*
|
|
||||||
* If not provided, no paywall tag will be shown.
|
* If not provided, no paywall tag will be shown.
|
||||||
*/
|
*/
|
||||||
readonly paywall?: Exclude<ReservedPlanId, ReservedPlanId.Free | ReservedPlanId.Development>;
|
readonly paywall?: PaywallPlanId;
|
||||||
readonly hasAddOnTag?: boolean;
|
readonly hasAddOnTag?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { isValidUrl } from '@logto/core-kit';
|
import { isValidUrl } from '@logto/core-kit';
|
||||||
import { ReservedPlanId, type Resource } from '@logto/schemas';
|
import { type Resource } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
@ -7,6 +7,7 @@ import { toast } from 'react-hot-toast';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import Modal from 'react-modal';
|
import Modal from 'react-modal';
|
||||||
|
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import ModalLayout from '@/ds-components/ModalLayout';
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
|
@ -43,6 +44,7 @@ function CreateForm({ onClose }: Props) {
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { hasReachedLimit } = useApiResourcesUsage();
|
const { hasReachedLimit } = useApiResourcesUsage();
|
||||||
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit(
|
||||||
trySubmitSafe(async (data) => {
|
trySubmitSafe(async (data) => {
|
||||||
|
@ -69,8 +71,8 @@ function CreateForm({ onClose }: Props) {
|
||||||
<ModalLayout
|
<ModalLayout
|
||||||
title="api_resources.create"
|
title="api_resources.create"
|
||||||
subtitle="api_resources.subtitle"
|
subtitle="api_resources.subtitle"
|
||||||
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
|
paywall={conditional(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan) && hasReachedLimit}
|
hasAddOnTag={isPaidTenant && hasReachedLimit}
|
||||||
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
|
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||||
|
@ -12,6 +11,7 @@ import { type SelectedGuide } from '@/components/Guide/GuideCard';
|
||||||
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
||||||
import { useAppGuideMetadata } from '@/components/Guide/hooks';
|
import { useAppGuideMetadata } from '@/components/Guide/hooks';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { CheckboxGroup } from '@/ds-components/Checkbox';
|
import { CheckboxGroup } from '@/ds-components/Checkbox';
|
||||||
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
||||||
|
@ -105,7 +105,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide }
|
||||||
isVisible={
|
isVisible={
|
||||||
currentSubscriptionQuota.thirdPartyApplicationsLimit === 0
|
currentSubscriptionQuota.thirdPartyApplicationsLimit === 0
|
||||||
}
|
}
|
||||||
plan={ReservedPlanId.Pro}
|
plan={latestProPlanId}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,13 @@ import { useCallback, useContext, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
|
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
|
||||||
import { isCloud } from '@/consts/env';
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import CardTitle from '@/ds-components/CardTitle';
|
import CardTitle from '@/ds-components/CardTitle';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
|
import { isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import CreateButton from './CreateButton';
|
import CreateButton from './CreateButton';
|
||||||
import CustomizerItem from './CustomizerItem';
|
import CustomizerItem from './CustomizerItem';
|
||||||
|
@ -23,12 +24,11 @@ function CustomizeJwt() {
|
||||||
|
|
||||||
const { isDevTenant } = useContext(TenantsContext);
|
const { isDevTenant } = useContext(TenantsContext);
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
currentSubscriptionQuota: { customJwtEnabled },
|
currentSubscriptionQuota: { customJwtEnabled },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
|
|
||||||
const { getDocumentationUrl } = useDocumentationUrl();
|
const { getDocumentationUrl } = useDocumentationUrl();
|
||||||
const isCustomJwtEnabled = !isCloud || customJwtEnabled;
|
|
||||||
|
|
||||||
const showPaywall = planId === ReservedPlanId.Free;
|
const showPaywall = planId === ReservedPlanId.Free;
|
||||||
|
|
||||||
|
@ -38,13 +38,15 @@ function CustomizeJwt() {
|
||||||
setDeleteModalTokenType(tokenType);
|
setDeleteModalTokenType(tokenType);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
const { isLoading, accessTokenJwtCustomizer, clientCredentialsJwtCustomizer } =
|
const { isLoading, accessTokenJwtCustomizer, clientCredentialsJwtCustomizer } =
|
||||||
useJwtCustomizer();
|
useJwtCustomizer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.mainContent}>
|
<main className={styles.mainContent}>
|
||||||
<CardTitle
|
<CardTitle
|
||||||
paywall={cond((!isCustomJwtEnabled || isDevTenant) && ReservedPlanId.Pro)}
|
paywall={cond(!isPaidTenant && latestProPlanId)}
|
||||||
title="jwt_claims.title"
|
title="jwt_claims.title"
|
||||||
subtitle="jwt_claims.description"
|
subtitle="jwt_claims.description"
|
||||||
learnMoreLink={{
|
learnMoreLink={{
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
ReservedPlanId,
|
|
||||||
type RequestErrorBody,
|
type RequestErrorBody,
|
||||||
type SsoConnectorProvidersResponse,
|
type SsoConnectorProvidersResponse,
|
||||||
type SsoConnectorWithProviderConfig,
|
type SsoConnectorWithProviderConfig,
|
||||||
|
@ -19,7 +18,7 @@ import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/uti
|
||||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
||||||
import { enterpriseSsoAddOnUnitPrice } from '@/consts/subscriptions';
|
import { enterpriseSsoAddOnUnitPrice, latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
|
@ -63,8 +62,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
|
||||||
const isSsoEnabled =
|
const isSsoEnabled =
|
||||||
!isCloud ||
|
!isCloud ||
|
||||||
currentSubscriptionQuota.enterpriseSsoLimit === null ||
|
currentSubscriptionQuota.enterpriseSsoLimit === null ||
|
||||||
currentSubscriptionQuota.enterpriseSsoLimit > 0 ||
|
currentSubscriptionQuota.enterpriseSsoLimit > 0;
|
||||||
planId === ReservedPlanId.Pro;
|
|
||||||
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
|
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
|
||||||
|
@ -155,7 +154,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
|
||||||
>
|
>
|
||||||
<ModalLayout
|
<ModalLayout
|
||||||
title="enterprise_sso.create_modal.title"
|
title="enterprise_sso.create_modal.title"
|
||||||
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
|
paywall={conditional(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidTenant}
|
hasAddOnTag={isPaidTenant}
|
||||||
footer={
|
footer={
|
||||||
conditional(
|
conditional(
|
||||||
|
@ -185,7 +184,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
|
||||||
</AddOnNoticeFooter>
|
</AddOnNoticeFooter>
|
||||||
)
|
)
|
||||||
) ??
|
) ??
|
||||||
(isSsoEnabled ? (
|
// Paid tenant can create SSO connectors
|
||||||
|
(isSsoEnabled || isPaidTenant ? (
|
||||||
<Button
|
<Button
|
||||||
title="enterprise_sso.create_modal.create_button_text"
|
title="enterprise_sso.create_modal.create_button_text"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type SsoConnectorWithProviderConfig, ReservedPlanId } from '@logto/schemas';
|
import { type SsoConnectorWithProviderConfig } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
@ -11,9 +11,8 @@ import EnterpriseSsoConnectorEmpty from '@/assets/images/sso-connector-empty.svg
|
||||||
import ItemPreview from '@/components/ItemPreview';
|
import ItemPreview from '@/components/ItemPreview';
|
||||||
import ListPage from '@/components/ListPage';
|
import ListPage from '@/components/ListPage';
|
||||||
import { defaultPageSize } from '@/consts';
|
import { defaultPageSize } from '@/consts';
|
||||||
import { isCloud } from '@/consts/env';
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
|
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
|
||||||
import Tag from '@/ds-components/Tag';
|
import Tag from '@/ds-components/Tag';
|
||||||
|
@ -36,19 +35,14 @@ function EnterpriseSso() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { isDevTenant } = useContext(TenantsContext);
|
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId, isEnterprisePlan },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
currentSubscriptionQuota,
|
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
|
|
||||||
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
|
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
|
||||||
page: 1,
|
page: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSsoEnabled =
|
|
||||||
!isCloud || currentSubscriptionQuota.enterpriseSsoLimit !== 0 || planId === ReservedPlanId.Pro;
|
|
||||||
|
|
||||||
const url = buildUrl('api/sso-connectors', {
|
const url = buildUrl('api/sso-connectors', {
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: String(pageSize),
|
page_size: String(pageSize),
|
||||||
|
@ -60,14 +54,15 @@ function EnterpriseSso() {
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
const [ssoConnectors, totalCount] = data ?? [];
|
const [ssoConnectors, totalCount] = data ?? [];
|
||||||
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPage
|
<ListPage
|
||||||
title={{
|
title={{
|
||||||
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
|
paywall: conditional(!isPaidTenant && latestProPlanId),
|
||||||
title: 'enterprise_sso.title',
|
title: 'enterprise_sso.title',
|
||||||
subtitle: 'enterprise_sso.subtitle',
|
subtitle: 'enterprise_sso.subtitle',
|
||||||
hasAddOnTag: isPaidPlan(planId, isEnterprisePlan),
|
hasAddOnTag: isPaidTenant,
|
||||||
}}
|
}}
|
||||||
pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
|
pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
|
||||||
createButton={conditional(
|
createButton={conditional(
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import InlineNotification from '@/ds-components/InlineNotification';
|
import InlineNotification from '@/ds-components/InlineNotification';
|
||||||
import TextLink from '@/ds-components/TextLink';
|
import TextLink from '@/ds-components/TextLink';
|
||||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||||
|
import { isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
@ -16,14 +16,16 @@ type Props = {
|
||||||
function UpsellNotice({ className }: Props) {
|
function UpsellNotice({ className }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const {
|
const {
|
||||||
data: { mfaUpsellNoticeAcknowledged },
|
data: { mfaUpsellNoticeAcknowledged },
|
||||||
update,
|
update,
|
||||||
} = useUserPreferences();
|
} = useUserPreferences();
|
||||||
|
|
||||||
if (planId !== ReservedPlanId.Pro || mfaUpsellNoticeAcknowledged) {
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
|
if (!isPaidTenant || mfaUpsellNoticeAcknowledged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import { useContext, type ReactNode } from 'react';
|
import { useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
import { isCloud } from '@/consts/env';
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
|
||||||
import CardTitle from '@/ds-components/CardTitle';
|
import CardTitle from '@/ds-components/CardTitle';
|
||||||
import { isPaidPlan } from '@/utils/subscription';
|
import { isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
|
@ -16,18 +14,18 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function PageWrapper({ children }: Props) {
|
function PageWrapper({ children }: Props) {
|
||||||
const { isDevTenant } = useContext(TenantsContext);
|
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId, isEnterprisePlan },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
currentSubscriptionQuota: { mfaEnabled },
|
currentSubscriptionQuota: { mfaEnabled },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const isMfaEnabled = !isCloud || mfaEnabled || planId === ReservedPlanId.Pro;
|
|
||||||
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<PageMeta titleKey="mfa.title" />
|
<PageMeta titleKey="mfa.title" />
|
||||||
<CardTitle
|
<CardTitle
|
||||||
paywall={cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)}
|
paywall={cond(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
|
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
|
||||||
title="mfa.title"
|
title="mfa.title"
|
||||||
subtitle="mfa.description"
|
subtitle="mfa.description"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useContext, useState } from 'react';
|
import { useCallback, useContext, useState } from 'react';
|
||||||
|
@ -11,8 +10,8 @@ import PageMeta from '@/components/PageMeta';
|
||||||
import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts';
|
import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import { subscriptionPage } from '@/consts/pages';
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import Card from '@/ds-components/Card';
|
import Card from '@/ds-components/Card';
|
||||||
import CardTitle from '@/ds-components/CardTitle';
|
import CardTitle from '@/ds-components/CardTitle';
|
||||||
|
@ -22,7 +21,7 @@ import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
|
||||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
import pageLayout from '@/scss/page-layout.module.scss';
|
import pageLayout from '@/scss/page-layout.module.scss';
|
||||||
import { isFeatureEnabled } from '@/utils/subscription';
|
import { isFeatureEnabled, isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import Introduction from '../Organizations/Introduction';
|
import Introduction from '../Organizations/Introduction';
|
||||||
|
|
||||||
|
@ -34,14 +33,16 @@ function OrganizationTemplate() {
|
||||||
const { getDocumentationUrl } = useDocumentationUrl();
|
const { getDocumentationUrl } = useDocumentationUrl();
|
||||||
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
|
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
currentSubscriptionQuota,
|
currentSubscriptionQuota,
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const { isDevTenant } = useContext(TenantsContext);
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
const isOrganizationsDisabled =
|
const isOrganizationsDisabled =
|
||||||
isCloud &&
|
// Check if the organizations feature is disabled except for paid tenants.
|
||||||
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
|
// Paid tenants can create organizations with organization feature add-on applied to their subscription.
|
||||||
planId !== ReservedPlanId.Pro;
|
isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
|
||||||
|
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
|
|
||||||
const handleUpgradePlan = useCallback(() => {
|
const handleUpgradePlan = useCallback(() => {
|
||||||
|
@ -59,7 +60,7 @@ function OrganizationTemplate() {
|
||||||
href: getDocumentationUrl(organizationTemplateLink),
|
href: getDocumentationUrl(organizationTemplateLink),
|
||||||
targetBlank: 'noopener',
|
targetBlank: 'noopener',
|
||||||
}}
|
}}
|
||||||
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
|
paywall={cond(!isPaidTenant && latestProPlanId)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
title="application_details.check_guide"
|
title="application_details.check_guide"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type Organization, type CreateOrganization, ReservedPlanId } from '@logto/schemas';
|
import { type Organization, type CreateOrganization } from '@logto/schemas';
|
||||||
import { cond, conditional } from '@silverhand/essentials';
|
import { cond, conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
@ -10,7 +10,7 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
||||||
import { organizationAddOnUnitPrice } from '@/consts/subscriptions';
|
import { latestProPlanId, organizationAddOnUnitPrice } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
|
@ -41,11 +41,11 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
|
||||||
data: { organizationUpsellNoticeAcknowledged },
|
data: { organizationUpsellNoticeAcknowledged },
|
||||||
update,
|
update,
|
||||||
} = useUserPreferences();
|
} = useUserPreferences();
|
||||||
const isOrganizationsDisabled =
|
|
||||||
isCloud &&
|
|
||||||
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
|
|
||||||
planId !== ReservedPlanId.Pro;
|
|
||||||
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
const isOrganizationsDisabled =
|
||||||
|
// Check if the organizations feature is disabled except for paid tenants.
|
||||||
|
// Paid tenants can create organizations with organization feature add-on applied to their subscription.
|
||||||
|
isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
reset,
|
reset,
|
||||||
|
@ -82,7 +82,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
|
||||||
>
|
>
|
||||||
<ModalLayout
|
<ModalLayout
|
||||||
title="organizations.create_organization"
|
title="organizations.create_organization"
|
||||||
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
|
paywall={conditional(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidTenant}
|
hasAddOnTag={isPaidTenant}
|
||||||
footer={
|
footer={
|
||||||
cond(
|
cond(
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import { useCallback, useContext, useState } from 'react';
|
import { useCallback, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
@ -7,6 +6,7 @@ import PageMeta from '@/components/PageMeta';
|
||||||
import { organizationsFeatureLink } from '@/consts';
|
import { organizationsFeatureLink } from '@/consts';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import { subscriptionPage } from '@/consts/pages';
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
|
@ -35,10 +35,10 @@ function Organizations() {
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
const isOrganizationsDisabled =
|
const isOrganizationsDisabled =
|
||||||
isCloud &&
|
isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
|
||||||
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
|
|
||||||
planId !== ReservedPlanId.Pro;
|
|
||||||
|
|
||||||
const upgradePlan = useCallback(() => {
|
const upgradePlan = useCallback(() => {
|
||||||
navigate(subscriptionPage);
|
navigate(subscriptionPage);
|
||||||
|
@ -63,7 +63,7 @@ function Organizations() {
|
||||||
<PageMeta titleKey="organizations.page_title" />
|
<PageMeta titleKey="organizations.page_title" />
|
||||||
<div className={pageLayout.headline}>
|
<div className={pageLayout.headline}>
|
||||||
<CardTitle
|
<CardTitle
|
||||||
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
|
paywall={cond(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
|
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
|
||||||
title="organizations.title"
|
title="organizations.title"
|
||||||
subtitle="organizations.subtitle"
|
subtitle="organizations.subtitle"
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import InlineUpsell from '@/components/InlineUpsell';
|
import InlineUpsell from '@/components/InlineUpsell';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import Card from '@/ds-components/Card';
|
import Card from '@/ds-components/Card';
|
||||||
import CodeEditor from '@/ds-components/CodeEditor';
|
import CodeEditor from '@/ds-components/CodeEditor';
|
||||||
|
@ -88,7 +88,7 @@ function CustomUiForm() {
|
||||||
descriptionPosition="top"
|
descriptionPosition="top"
|
||||||
featureTag={{
|
featureTag={{
|
||||||
isVisible: !isBringYourUiEnabled,
|
isVisible: !isBringYourUiEnabled,
|
||||||
plan: ReservedPlanId.Pro,
|
plan: latestProPlanId,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import InlineNotification from '@/ds-components/InlineNotification';
|
import InlineNotification from '@/ds-components/InlineNotification';
|
||||||
import TextLink from '@/ds-components/TextLink';
|
import TextLink from '@/ds-components/TextLink';
|
||||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||||
|
import { isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
@ -16,14 +16,16 @@ type Props = {
|
||||||
function AddOnUsageChangesNotification({ className }: Props) {
|
function AddOnUsageChangesNotification({ className }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const {
|
const {
|
||||||
currentSubscription: { planId },
|
currentSubscription: { planId, isEnterprisePlan },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const {
|
const {
|
||||||
data: { addOnChangesInCurrentCycleNoticeAcknowledged },
|
data: { addOnChangesInCurrentCycleNoticeAcknowledged },
|
||||||
update,
|
update,
|
||||||
} = useUserPreferences();
|
} = useUserPreferences();
|
||||||
|
|
||||||
if (planId !== ReservedPlanId.Pro || addOnChangesInCurrentCycleNoticeAcknowledged) {
|
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
|
||||||
|
|
||||||
|
if (!isPaidTenant || addOnChangesInCurrentCycleNoticeAcknowledged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useMemo, useState } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
||||||
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||||
import { subscriptionPage } from '@/consts/pages';
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
|
import { latestProPlanId } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
|
@ -27,7 +27,7 @@ function MauLimitExceededNotification({ periodicUsage: rawPeriodicUsage, classNa
|
||||||
const { currentTenant } = useContext(TenantsContext);
|
const { currentTenant } = useContext(TenantsContext);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const proSku = useMemo(() => logtoSkus.find(({ id }) => id === ReservedPlanId.Pro), [logtoSkus]);
|
const proSku = useMemo(() => logtoSkus.find(({ id }) => id === latestProPlanId), [logtoSkus]);
|
||||||
|
|
||||||
const periodicUsage = useMemo(
|
const periodicUsage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ReservedPlanId, TenantRole } from '@logto/schemas';
|
import { TenantRole } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useEffect, useMemo, useState } from 'react';
|
import { useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||||
|
@ -9,7 +9,7 @@ import ReactModal from 'react-modal';
|
||||||
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
|
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
|
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
|
||||||
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
import { addOnPricingExplanationLink } from '@/consts/external-links';
|
||||||
import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
|
import { latestProPlanId, tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
|
@ -127,7 +127,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
|
||||||
<ModalLayout
|
<ModalLayout
|
||||||
size="large"
|
size="large"
|
||||||
title="tenant_members.invite_modal.title"
|
title="tenant_members.invite_modal.title"
|
||||||
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
|
paywall={conditional(!isPaidTenant && latestProPlanId)}
|
||||||
hasAddOnTag={isPaidTenant && hasTenantMembersReachedLimit}
|
hasAddOnTag={isPaidTenant && hasTenantMembersReachedLimit}
|
||||||
subtitle="tenant_members.invite_modal.subtitle"
|
subtitle="tenant_members.invite_modal.subtitle"
|
||||||
footer={
|
footer={
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
@ -14,6 +13,7 @@ import {
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus';
|
import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus';
|
||||||
import { sortBy } from '@/utils/sort';
|
import { sortBy } from '@/utils/sort';
|
||||||
|
import { isProPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ export function NotEligibleSwitchSkuModalContent({
|
||||||
>
|
>
|
||||||
{t(isDowngrade ? 'downgrade_description' : 'upgrade_description')}
|
{t(isDowngrade ? 'downgrade_description' : 'upgrade_description')}
|
||||||
</Trans>
|
</Trans>
|
||||||
{!isDowngrade && id === ReservedPlanId.Pro && t('upgrade_pro_tip')}
|
{!isDowngrade && isProPlan(id) && t('upgrade_pro_tip')}
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{orderedEntries.map(([quotaKey, quotaValue]) => {
|
{orderedEntries.map(([quotaKey, quotaValue]) => {
|
||||||
|
|
|
@ -102,8 +102,17 @@ export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSku
|
||||||
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id));
|
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id));
|
||||||
|
|
||||||
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
|
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
|
||||||
planId === ReservedPlanId.Pro || isEnterprisePlan;
|
isProPlan(planId) || isEnterprisePlan;
|
||||||
|
|
||||||
export const isFeatureEnabled = (quota: Nullable<number>): boolean => {
|
export const isFeatureEnabled = (quota: Nullable<number>): boolean => {
|
||||||
return quota === null || quota > 0;
|
return quota === null || quota > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We may have more than one pro planId in the future.
|
||||||
|
* E.g grandfathered {@link ReservedPlanId.Pro} and new {@link ReservedPlanId.Pro202411}.
|
||||||
|
* User this function to check if the planId can be considered as a pro plan.
|
||||||
|
*/
|
||||||
|
export const isProPlan = (planId: string) =>
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
[ReservedPlanId.Pro, ReservedPlanId.Pro202411].includes(planId as ReservedPlanId);
|
||||||
|
|
Loading…
Reference in a new issue