0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -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:
simeng-li 2024-12-12 14:26:51 +08:00 committed by GitHub
parent 788270b5a7
commit 69986bc179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 124 additions and 88 deletions

View file

@ -1,6 +1,6 @@
import { type AdminConsoleKey } from '@logto/phrases';
import type { Application } from '@logto/schemas';
import { ApplicationType, ReservedPlanId } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { type ReactElement, useContext, useMemo } from 'react';
import { useController, useForm } from 'react-hook-form';
@ -11,6 +11,7 @@ import { useSWRConfig } from 'swr';
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import { isDevFeaturesEnabled } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
@ -142,10 +143,7 @@ function CreateForm({
title="applications.create"
subtitle={subtitleElement}
paywall={conditional(
isPaidTenant &&
watch('type') === ApplicationType.MachineToMachine &&
planId !== ReservedPlanId.Pro &&
ReservedPlanId.Pro
!isPaidTenant && watch('type') === ApplicationType.MachineToMachine && latestProPlanId
)}
hasAddOnTag={
isPaidTenant &&

View file

@ -14,6 +14,7 @@ import DangerousRaw from '@/ds-components/DangerousRaw';
import DynamicT from '@/ds-components/DynamicT';
import FlipOnRtl from '@/ds-components/FlipOnRtl';
import TextLink from '@/ds-components/TextLink';
import { isProPlan } from '@/utils/subscription';
import FeaturedSkuContent from './FeaturedSkuContent';
import styles from './index.module.scss';
@ -92,9 +93,7 @@ function SkuCardItem({ sku, onSelect, buttonProps }: Props) {
disabled={(isFreeSku && isFreeTenantExceeded) || buttonProps?.disabled}
/>
</div>
{skuId === ReservedPlanId.Pro && (
<div className={styles.mostPopularTag}>{t('most_popular')}</div>
)}
{isProPlan(skuId) && <div className={styles.mostPopularTag}>{t('most_popular')}</div>}
</div>
);
}

View file

@ -5,11 +5,29 @@ import { useContext } from 'react';
import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { isProPlan } from '@/utils/subscription';
import styles from './index.module.scss';
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 = {
/**
* Whether the tag should be visible. It should be `true` if the tenant's subscription
@ -17,8 +35,12 @@ export type Props = {
* tenants.
*/
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;
};
@ -61,7 +83,7 @@ function FeatureTag(props: Props) {
return null;
}
return <div className={classNames(styles.tag, className)}>{plan}</div>;
return <div className={classNames(styles.tag, className)}>{planIdTagMap[plan]}</div>;
}
export default FeatureTag;
@ -89,7 +111,7 @@ export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProp
}
// Show the "Add-on" tag for Pro plan.
if (hasAddOnTag && isCloud && planId === ReservedPlanId.Pro) {
if (hasAddOnTag && isCloud && isProPlan(planId)) {
return (
<div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div>
);

View file

@ -1,9 +1,10 @@
import { ReservedPlanId, Theme } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { Suspense, useCallback } from 'react';
import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types';
import FeatureTag, { BetaTag } from '@/components/FeatureTag';
import { latestProPlanId } from '@/consts/subscriptions';
import Button from '@/ds-components/Button';
import useTheme from '@/hooks/use-theme';
import { onKeyDownHandler } from '@/utils/a11y';
@ -61,7 +62,7 @@ function GuideCard({ data, onClick, hasBorder, hasButton, hasPaywall, isBeta }:
<div className={styles.name}>{name}</div>
{hasTags && (
<div className={styles.tagWrapper}>
{hasPaywall && <FeatureTag isVisible plan={ReservedPlanId.Pro} />}
{hasPaywall && <FeatureTag isVisible plan={latestProPlanId} />}
{isBeta && <BetaTag />}
</div>
)}

View file

@ -9,6 +9,7 @@ const registeredPlanDescriptionPhrasesMap: Record<
> = {
[ReservedPlanId.Free]: 'free_plan_description',
[ReservedPlanId.Pro]: 'pro_plan_description',
[ReservedPlanId.Pro202411]: 'pro_plan_description',
};
const getRegisteredPlanDescriptionPhrase = (

View file

@ -12,7 +12,7 @@ import {
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
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 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.
// Per current pricing model design, it should apply to `enterpriseSsoLimit`.
...cond(
planId === ReservedPlanId.Pro &&
isProPlan(planId) &&
currentSubscriptionBasicQuota[key] === 0 && {
isQuotaNoticeHidden: true,
}

View file

@ -8,6 +8,7 @@ import { type LogtoSkuQuota } from '@/types/skus';
export const ticketSupportResponseTimeMap: Record<string, number> = {
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Pro]: 48,
[ReservedPlanId.Pro202411]: 48,
};
/**

View file

@ -1,5 +1,7 @@
import { ReservedPlanId } from '@logto/schemas';
import { isDevFeaturesEnabled } from './env';
/**
* 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 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;

View file

@ -1,10 +1,9 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { type ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactElement } from 'react';
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 DangerousRaw from '../DangerousRaw';
@ -22,10 +21,9 @@ export type Props = {
readonly className?: string;
/**
* 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.
*/
readonly paywall?: Exclude<ReservedPlanId, ReservedPlanId.Free | ReservedPlanId.Development>;
readonly paywall?: PaywallPlanId;
readonly hasAddOnTag?: boolean;
};

View file

@ -1,5 +1,5 @@
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 { useContext } from 'react';
import { useForm } from 'react-hook-form';
@ -7,6 +7,7 @@ import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
@ -43,6 +44,7 @@ function CreateForm({ onClose }: Props) {
const api = useApi();
const { hasReachedLimit } = useApiResourcesUsage();
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
@ -69,8 +71,8 @@ function CreateForm({ onClose }: Props) {
<ModalLayout
title="api_resources.create"
subtitle="api_resources.subtitle"
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan) && hasReachedLimit}
paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant && hasReachedLimit}
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
onClose={onClose}
>

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import classNames from 'classnames';
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 { useAppGuideMetadata } from '@/components/Guide/hooks';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { CheckboxGroup } from '@/ds-components/Checkbox';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
@ -105,7 +105,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide }
isVisible={
currentSubscriptionQuota.thirdPartyApplicationsLimit === 0
}
plan={ReservedPlanId.Pro}
plan={latestProPlanId}
/>
),
}

View file

@ -4,12 +4,13 @@ import { useCallback, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
import FormField from '@/ds-components/FormField';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { isPaidPlan } from '@/utils/subscription';
import CreateButton from './CreateButton';
import CustomizerItem from './CustomizerItem';
@ -23,12 +24,11 @@ function CustomizeJwt() {
const { isDevTenant } = useContext(TenantsContext);
const {
currentSubscription: { planId },
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota: { customJwtEnabled },
} = useContext(SubscriptionDataContext);
const { getDocumentationUrl } = useDocumentationUrl();
const isCustomJwtEnabled = !isCloud || customJwtEnabled;
const showPaywall = planId === ReservedPlanId.Free;
@ -38,13 +38,15 @@ function CustomizeJwt() {
setDeleteModalTokenType(tokenType);
}, []);
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const { isLoading, accessTokenJwtCustomizer, clientCredentialsJwtCustomizer } =
useJwtCustomizer();
return (
<main className={styles.mainContent}>
<CardTitle
paywall={cond((!isCustomJwtEnabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={cond(!isPaidTenant && latestProPlanId)}
title="jwt_claims.title"
subtitle="jwt_claims.description"
learnMoreLink={{

View file

@ -1,5 +1,4 @@
import {
ReservedPlanId,
type RequestErrorBody,
type SsoConnectorProvidersResponse,
type SsoConnectorWithProviderConfig,
@ -19,7 +18,7 @@ import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/uti
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { enterpriseSsoAddOnUnitPrice } from '@/consts/subscriptions';
import { enterpriseSsoAddOnUnitPrice, latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
@ -63,8 +62,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const isSsoEnabled =
!isCloud ||
currentSubscriptionQuota.enterpriseSsoLimit === null ||
currentSubscriptionQuota.enterpriseSsoLimit > 0 ||
planId === ReservedPlanId.Pro;
currentSubscriptionQuota.enterpriseSsoLimit > 0;
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
@ -155,7 +154,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
>
<ModalLayout
title="enterprise_sso.create_modal.title"
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant}
footer={
conditional(
@ -185,7 +184,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
</AddOnNoticeFooter>
)
) ??
(isSsoEnabled ? (
// Paid tenant can create SSO connectors
(isSsoEnabled || isPaidTenant ? (
<Button
title="enterprise_sso.create_modal.create_button_text"
type="primary"

View file

@ -1,4 +1,4 @@
import { type SsoConnectorWithProviderConfig, ReservedPlanId } from '@logto/schemas';
import { type SsoConnectorWithProviderConfig } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
@ -11,9 +11,8 @@ import EnterpriseSsoConnectorEmpty from '@/assets/images/sso-connector-empty.svg
import ItemPreview from '@/components/ItemPreview';
import ListPage from '@/components/ListPage';
import { defaultPageSize } from '@/consts';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import Tag from '@/ds-components/Tag';
@ -36,19 +35,14 @@ function EnterpriseSso() {
const { pathname } = useLocation();
const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isDevTenant } = useContext(TenantsContext);
const {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
page: 1,
});
const isSsoEnabled =
!isCloud || currentSubscriptionQuota.enterpriseSsoLimit !== 0 || planId === ReservedPlanId.Pro;
const url = buildUrl('api/sso-connectors', {
page: String(page),
page_size: String(pageSize),
@ -60,14 +54,15 @@ function EnterpriseSso() {
const isLoading = !data && !error;
const [ssoConnectors, totalCount] = data ?? [];
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
return (
<ListPage
title={{
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
paywall: conditional(!isPaidTenant && latestProPlanId),
title: 'enterprise_sso.title',
subtitle: 'enterprise_sso.subtitle',
hasAddOnTag: isPaidPlan(planId, isEnterprisePlan),
hasAddOnTag: isPaidTenant,
}}
pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
createButton={conditional(

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink';
import useUserPreferences from '@/hooks/use-user-preferences';
import { isPaidPlan } from '@/utils/subscription';
type Props = {
readonly className?: string;
@ -16,14 +16,16 @@ type Props = {
function UpsellNotice({ className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId },
currentSubscription: { planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext);
const {
data: { mfaUpsellNoticeAcknowledged },
update,
} = useUserPreferences();
if (planId !== ReservedPlanId.Pro || mfaUpsellNoticeAcknowledged) {
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
if (!isPaidTenant || mfaUpsellNoticeAcknowledged) {
return null;
}

View file

@ -1,11 +1,9 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { useContext, type ReactNode } from 'react';
import PageMeta from '@/components/PageMeta';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
import { isPaidPlan } from '@/utils/subscription';
@ -16,18 +14,18 @@ type Props = {
};
function PageWrapper({ children }: Props) {
const { isDevTenant } = useContext(TenantsContext);
const {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota: { mfaEnabled },
} = useContext(SubscriptionDataContext);
const isMfaEnabled = !isCloud || mfaEnabled || planId === ReservedPlanId.Pro;
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
return (
<div className={styles.container}>
<PageMeta titleKey="mfa.title" />
<CardTitle
paywall={cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={cond(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
title="mfa.title"
subtitle="mfa.description"

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import classNames from 'classnames';
import { useCallback, useContext, useState } from 'react';
@ -11,8 +10,8 @@ import PageMeta from '@/components/PageMeta';
import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts';
import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
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 useTenantPathname from '@/hooks/use-tenant-pathname';
import pageLayout from '@/scss/page-layout.module.scss';
import { isFeatureEnabled } from '@/utils/subscription';
import { isFeatureEnabled, isPaidPlan } from '@/utils/subscription';
import Introduction from '../Organizations/Introduction';
@ -34,14 +33,16 @@ function OrganizationTemplate() {
const { getDocumentationUrl } = useDocumentationUrl();
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
const {
currentSubscription: { planId },
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext);
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const isOrganizationsDisabled =
isCloud &&
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
planId !== ReservedPlanId.Pro;
// 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 { navigate } = useTenantPathname();
const handleUpgradePlan = useCallback(() => {
@ -59,7 +60,7 @@ function OrganizationTemplate() {
href: getDocumentationUrl(organizationTemplateLink),
targetBlank: 'noopener',
}}
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={cond(!isPaidTenant && latestProPlanId)}
/>
<Button
title="application_details.check_guide"

View file

@ -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 { useContext, useEffect } from 'react';
import { useForm } from 'react-hook-form';
@ -10,7 +10,7 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { organizationAddOnUnitPrice } from '@/consts/subscriptions';
import { latestProPlanId, organizationAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
@ -41,11 +41,11 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
data: { organizationUpsellNoticeAcknowledged },
update,
} = useUserPreferences();
const isOrganizationsDisabled =
isCloud &&
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
planId !== ReservedPlanId.Pro;
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 {
reset,
@ -82,7 +82,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
>
<ModalLayout
title="organizations.create_organization"
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant}
footer={
cond(

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { useCallback, useContext, useState } from 'react';
@ -7,6 +6,7 @@ import PageMeta from '@/components/PageMeta';
import { organizationsFeatureLink } from '@/consts';
import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
@ -35,10 +35,10 @@ function Organizations() {
const { navigate } = useTenantPathname();
const [isCreating, setIsCreating] = useState(false);
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const isOrganizationsDisabled =
isCloud &&
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
planId !== ReservedPlanId.Pro;
isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
const upgradePlan = useCallback(() => {
navigate(subscriptionPage);
@ -63,7 +63,7 @@ function Organizations() {
<PageMeta titleKey="organizations.page_title" />
<div className={pageLayout.headline}>
<CardTitle
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={cond(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
title="organizations.title"
subtitle="organizations.subtitle"

View file

@ -1,10 +1,10 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import InlineUpsell from '@/components/InlineUpsell';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Card from '@/ds-components/Card';
import CodeEditor from '@/ds-components/CodeEditor';
@ -88,7 +88,7 @@ function CustomUiForm() {
descriptionPosition="top"
featureTag={{
isVisible: !isBringYourUiEnabled,
plan: ReservedPlanId.Pro,
plan: latestProPlanId,
}}
>
<Controller

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink';
import useUserPreferences from '@/hooks/use-user-preferences';
import { isPaidPlan } from '@/utils/subscription';
type Props = {
readonly className?: string;
@ -16,14 +16,16 @@ type Props = {
function AddOnUsageChangesNotification({ className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId },
currentSubscription: { planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext);
const {
data: { addOnChangesInCurrentCycleNoticeAcknowledged },
update,
} = useUserPreferences();
if (planId !== ReservedPlanId.Pro || addOnChangesInCurrentCycleNoticeAcknowledged) {
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
if (!isPaidTenant || addOnChangesInCurrentCycleNoticeAcknowledged) {
return null;
}

View file

@ -1,10 +1,10 @@
import { ReservedPlanId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext, useMemo, useState } from 'react';
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT';
@ -27,7 +27,7 @@ function MauLimitExceededNotification({ periodicUsage: rawPeriodicUsage, classNa
const { currentTenant } = useContext(TenantsContext);
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(
() =>

View file

@ -1,4 +1,4 @@
import { ReservedPlanId, TenantRole } from '@logto/schemas';
import { TenantRole } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext, useEffect, useMemo, useState } from 'react';
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 AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
import { latestProPlanId, tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import FormField from '@/ds-components/FormField';
@ -127,7 +127,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<ModalLayout
size="large"
title="tenant_members.invite_modal.title"
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant && hasTenantMembersReachedLimit}
subtitle="tenant_members.invite_modal.subtitle"
footer={

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@ -14,6 +13,7 @@ import {
import DynamicT from '@/ds-components/DynamicT';
import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus';
import { sortBy } from '@/utils/sort';
import { isProPlan } from '@/utils/subscription';
import styles from './index.module.scss';
@ -64,7 +64,7 @@ export function NotEligibleSwitchSkuModalContent({
>
{t(isDowngrade ? 'downgrade_description' : 'upgrade_description')}
</Trans>
{!isDowngrade && id === ReservedPlanId.Pro && t('upgrade_pro_tip')}
{!isDowngrade && isProPlan(id) && t('upgrade_pro_tip')}
</div>
<ul className={styles.list}>
{orderedEntries.map(([quotaKey, quotaValue]) => {

View file

@ -102,8 +102,17 @@ export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSku
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id));
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
planId === ReservedPlanId.Pro || isEnterprisePlan;
isProPlan(planId) || isEnterprisePlan;
export const isFeatureEnabled = (quota: Nullable<number>): boolean => {
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);