0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(console,core): remove add on dev feature guard (#6466)

This commit is contained in:
Darcy Ye 2024-08-19 16:47:12 +08:00 committed by GitHub
parent 13bfa0641b
commit b549a7efd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 412 additions and 2037 deletions

View file

@ -6,7 +6,7 @@ import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { machineToMachineAddOnUnitPrice } from '@/consts/subscriptions'; import { machineToMachineAddOnUnitPrice } 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';
@ -24,7 +24,11 @@ type Props = {
}; };
function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) { function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) {
const { currentPlan, currentSku } = useContext(SubscriptionDataContext); const {
currentPlan,
currentSku,
currentSubscription: { isAddOnAvailable },
} = useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' });
const { const {
hasAppsReachedLimit, hasAppsReachedLimit,
@ -41,7 +45,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
if ( if (
selectedType === ApplicationType.MachineToMachine && selectedType === ApplicationType.MachineToMachine &&
isDevFeaturesEnabled && isAddOnAvailable &&
hasMachineToMachineAppsReachedLimit && hasMachineToMachineAppsReachedLimit &&
planId === ReservedPlanId.Pro && planId === ReservedPlanId.Pro &&
!m2mUpsellNoticeAcknowledged !m2mUpsellNoticeAcknowledged
@ -58,7 +62,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
<Trans <Trans
components={{ components={{
span: <span className={styles.strong} />, span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('add_on.footer.machine_to_machine_app', { {t('add_on.footer.machine_to_machine_app', {
@ -73,7 +77,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
selectedType === ApplicationType.MachineToMachine && selectedType === ApplicationType.MachineToMachine &&
hasMachineToMachineAppsReachedLimit && hasMachineToMachineAppsReachedLimit &&
// For paid plan (pro plan), we don't guard the m2m app creation since it's an add-on feature. // For paid plan (pro plan), we don't guard the m2m app creation since it's an add-on feature.
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Free currentSku.id === ReservedPlanId.Free
) { ) {
return ( return (
<QuotaGuardFooter> <QuotaGuardFooter>

View file

@ -10,7 +10,6 @@ import Modal from 'react-modal';
import { useSWRConfig } from 'swr'; import { useSWRConfig } from 'swr';
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils'; import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import { isDevFeaturesEnabled } from '@/consts/env';
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';
@ -57,7 +56,7 @@ function CreateForm({
defaultValues: { type: defaultCreateType, isThirdParty: isDefaultCreateThirdParty }, defaultValues: { type: defaultCreateType, isThirdParty: isDefaultCreateThirdParty },
}); });
const { const {
currentSubscription: { planId }, currentSubscription: { isAddOnAvailable },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutate: mutateGlobal } = useSWRConfig(); const { mutate: mutateGlobal } = useSWRConfig();
@ -123,12 +122,11 @@ function CreateForm({
title="applications.create" title="applications.create"
subtitle={subtitleElement} subtitle={subtitleElement}
paywall={conditional( paywall={conditional(
isDevFeaturesEnabled && isAddOnAvailable &&
watch('type') === ApplicationType.MachineToMachine && watch('type') === ApplicationType.MachineToMachine &&
planId === ReservedPlanId.Pro &&
ReservedPlanId.Pro ReservedPlanId.Pro
)} )}
hasAddOnTag={isDevFeaturesEnabled && watch('type') === ApplicationType.MachineToMachine} hasAddOnTag={isAddOnAvailable && watch('type') === ApplicationType.MachineToMachine}
size={defaultCreateType ? 'medium' : 'large'} size={defaultCreateType ? 'medium' : 'large'}
footer={ footer={
<Footer <Footer

View file

@ -5,7 +5,6 @@ import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { newPlansBlogLink } from '@/consts'; import { newPlansBlogLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; 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';
@ -39,7 +38,7 @@ function ChargeNotification({
checkedFlagKey, checkedFlagKey,
}: Props) { }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' });
const { currentPlan, currentSku } = useContext(SubscriptionDataContext); const { currentSku } = useContext(SubscriptionDataContext);
const { configs, updateConfigs } = useConfigs(); const { configs, updateConfigs } = useConfigs();
// Display null when loading // Display null when loading
@ -53,7 +52,7 @@ function ChargeNotification({
Boolean(checkedChargeNotification?.[checkedFlagKey]) || Boolean(checkedChargeNotification?.[checkedFlagKey]) ||
!hasSurpassedLimit || !hasSurpassedLimit ||
// No charge notification for free plan // No charge notification for free plan
(isDevFeaturesEnabled ? currentSku.id : currentPlan.id) === ReservedPlanId.Free currentSku.id === ReservedPlanId.Free
) { ) {
return null; return null;
} }

View file

@ -1,24 +1,17 @@
import { import { type ConnectorFactoryResponse } from '@logto/schemas';
ConnectorType, import { useContext } from 'react';
type ConnectorResponse,
type ConnectorFactoryResponse,
ReservedPlanId,
} from '@logto/schemas';
import { useContext, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import { type ConnectorGroup } from '@/types/connector'; import { type ConnectorGroup } from '@/types/connector';
import { hasReachedQuotaLimit, hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = { type Props = {
readonly isCreatingSocialConnector: boolean; readonly isCreatingSocialConnector: boolean;
readonly existingConnectors: ConnectorResponse[];
readonly selectedConnectorGroup?: ConnectorGroup<ConnectorFactoryResponse>; readonly selectedConnectorGroup?: ConnectorGroup<ConnectorFactoryResponse>;
readonly isCreateButtonDisabled: boolean; readonly isCreateButtonDisabled: boolean;
readonly onClickCreateButton: () => void; readonly onClickCreateButton: () => void;
@ -26,7 +19,6 @@ type Props = {
function Footer({ function Footer({
isCreatingSocialConnector, isCreatingSocialConnector,
existingConnectors,
selectedConnectorGroup, selectedConnectorGroup,
isCreateButtonDisabled, isCreateButtonDisabled,
onClickCreateButton, onClickCreateButton,
@ -35,75 +27,14 @@ function Footer({
const { currentPlan, currentSku, currentSubscriptionUsage, currentSubscriptionQuota } = const { currentPlan, currentSku, currentSubscriptionUsage, currentSubscriptionQuota } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const standardConnectorCount = useMemo( const isSocialConnectorsReachLimit = hasReachedSubscriptionQuotaLimit({
() => quotaKey: 'socialConnectorsLimit',
isDevFeaturesEnabled usage: currentSubscriptionUsage.socialConnectorsLimit,
? // No more standard connector limit in new pricing model. quota: currentSubscriptionQuota,
0 });
: existingConnectors.filter(
({ isStandard, isDemo, type }) => isStandard && !isDemo && type === ConnectorType.Social
).length,
[existingConnectors]
);
const socialConnectorCount = useMemo(
() =>
isDevFeaturesEnabled
? currentSubscriptionUsage.socialConnectorsLimit
: existingConnectors.filter(
({ isStandard, isDemo, type }) =>
!isStandard && !isDemo && type === ConnectorType.Social
).length,
[existingConnectors, currentSubscriptionUsage.socialConnectorsLimit]
);
const isStandardConnectorsReachLimit = isDevFeaturesEnabled
? // No more standard connector limit in new pricing model.
false
: hasReachedQuotaLimit({
quotaKey: 'standardConnectorsLimit',
plan: currentPlan,
usage: standardConnectorCount,
});
const isSocialConnectorsReachLimit = isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'socialConnectorsLimit',
usage: currentSubscriptionUsage.socialConnectorsLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: 'socialConnectorsLimit',
plan: currentPlan,
usage: socialConnectorCount,
});
if (isCreatingSocialConnector && selectedConnectorGroup) { if (isCreatingSocialConnector && selectedConnectorGroup) {
const { id: planId, name: planName, quota } = currentPlan; const { name: planName } = currentPlan;
if (isStandardConnectorsReachLimit && selectedConnectorGroup.isStandard) {
return (
<QuotaGuardFooter>
<Trans
components={{
a: <ContactUsPhraseLink />,
planName: <PlanName skuId={currentSku.id} name={planName} />,
}}
>
{quota.standardConnectorsLimit === 0
? t('standard_connectors_feature')
: t(
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Pro
? 'standard_connectors_pro'
: 'standard_connectors',
{
count: quota.standardConnectorsLimit ?? 0,
}
)}
</Trans>
</QuotaGuardFooter>
);
}
if (isSocialConnectorsReachLimit && !selectedConnectorGroup.isStandard) { if (isSocialConnectorsReachLimit && !selectedConnectorGroup.isStandard) {
return ( return (
@ -115,10 +46,7 @@ function Footer({
}} }}
> >
{t('social_connectors', { {t('social_connectors', {
count: count: currentSubscriptionQuota.socialConnectorsLimit ?? 0,
(isDevFeaturesEnabled
? currentSubscriptionQuota.socialConnectorsLimit
: quota.socialConnectorsLimit) ?? 0,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>

View file

@ -121,7 +121,6 @@ function CreateConnectorForm({ onClose, isOpen: isFormOpen, type }: Props) {
existingConnectors && ( existingConnectors && (
<Footer <Footer
isCreatingSocialConnector={isCreatingSocialConnector} isCreatingSocialConnector={isCreatingSocialConnector}
existingConnectors={existingConnectors}
selectedConnectorGroup={activeGroup} selectedConnectorGroup={activeGroup}
isCreateButtonDisabled={!activeFactoryId} isCreateButtonDisabled={!activeFactoryId}
onClickCreateButton={() => { onClickCreateButton={() => {

View file

@ -1,34 +0,0 @@
import classNames from 'classnames';
import Failed from '@/assets/icons/failed.svg?react';
import Success from '@/assets/icons/success.svg?react';
import styles from './index.module.scss';
import useFeaturedPlanContent from './use-featured-plan-content';
type Props = {
readonly planId: string;
};
function FeaturedPlanContent({ planId }: Props) {
const contentData = useFeaturedPlanContent(planId);
return (
<ul className={styles.list}>
{contentData.map(({ title, isAvailable }) => {
return (
<li key={title}>
{isAvailable ? (
<Success className={classNames(styles.icon, styles.success)} />
) : (
<Failed className={classNames(styles.icon, styles.failed)} />
)}
{title}
</li>
);
})}
</ul>
);
}
export default FeaturedPlanContent;

View file

@ -1,77 +0,0 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
freePlanAuditLogsRetentionDays,
freePlanM2mLimit,
freePlanMauLimit,
freePlanPermissionsLimit,
freePlanRoleLimit,
proPlanAuditLogsRetentionDays,
} from '@/consts/subscriptions';
type ContentData = {
title: string;
isAvailable: boolean;
};
const useFeaturedPlanContent = (planId: string) => {
const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console.upsell.featured_plan_content',
});
const contentData: ContentData[] = useMemo(() => {
const isFreePlan = planId === ReservedPlanId.Free;
const planPhraseKey = isFreePlan ? 'free_plan' : 'pro_plan';
return [
{
title: t(`mau.${planPhraseKey}`, { ...cond(isFreePlan && { count: freePlanMauLimit }) }),
isAvailable: true,
},
{
title: t(`m2m.${planPhraseKey}`, { ...cond(isFreePlan && { count: freePlanM2mLimit }) }),
isAvailable: true,
},
{
title: t('third_party_apps'),
isAvailable: !isFreePlan,
},
{
title: t('mfa'),
isAvailable: !isFreePlan,
},
{
title: t('sso'),
isAvailable: !isFreePlan,
},
{
title: t(`role_and_permissions.${planPhraseKey}`, {
...cond(
isFreePlan && {
roleCount: freePlanRoleLimit,
permissionCount: freePlanPermissionsLimit,
}
),
}),
isAvailable: true,
},
{
title: t('organizations'),
isAvailable: !isFreePlan,
},
{
title: t('audit_logs', {
count: isFreePlan ? freePlanAuditLogsRetentionDays : proPlanAuditLogsRetentionDays,
}),
isAvailable: true,
},
];
}, [t, planId]);
return contentData;
};
export default useFeaturedPlanContent;

View file

@ -1,103 +0,0 @@
@use '@/scss/underscore' as _;
.container {
position: relative;
flex: 1;
border-radius: 12px;
border: 1px solid var(--color-divider);
display: flex;
flex-direction: column;
}
.planInfo {
padding: _.unit(6);
border-bottom: 1px solid var(--color-divider);
> div:not(:first-child) {
margin-top: _.unit(4);
}
.title {
font: var(--font-headline-2);
}
.priceInfo {
> div:not(:first-child) {
margin-top: _.unit(1);
}
.priceLabel {
font: var(--font-body-3);
color: var(--color-text-secondary);
}
.price {
font: var(--font-headline-3);
}
}
.description {
margin-top: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
height: 40px;
}
}
.content {
flex: 1;
padding: _.unit(6);
display: flex;
flex-direction: column;
.tip {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: _.unit(4);
&.exceedFreeTenantsTip {
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.link {
font: var(--font-label-2);
display: flex;
align-items: center;
}
.linkIcon {
width: 16px;
height: 16px;
}
}
.list {
flex: 1;
padding-bottom: _.unit(8);
}
}
.mostPopularTag {
position: absolute;
border-radius: 4px 4px 0;
font: var(--font-label-3);
padding: _.unit(1.5) _.unit(2) _.unit(1.5) _.unit(2.5);
color: var(--color-white);
background-color: var(--color-specific-tag-upsell);
right: -5px;
top: _.unit(6);
width: 64px;
text-align: center;
&::after {
display: block;
content: '';
position: absolute;
right: 0;
bottom: -3px;
border-left: 4px solid var(--color-primary-60);
border-bottom: 3px solid transparent;
}
}

View file

@ -1,102 +0,0 @@
import { maxFreeTenantLimit, adminTenantId, ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames';
import { useContext, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import ArrowRight from '@/assets/icons/arrow-right.svg?react';
import PlanDescription from '@/components/PlanDescription';
import PlanName from '@/components/PlanName';
import { pricingLink } from '@/consts';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import DynamicT from '@/ds-components/DynamicT';
import TextLink from '@/ds-components/TextLink';
import { type SubscriptionPlan } from '@/types/subscriptions';
import FeaturedPlanContent from './FeaturedPlanContent';
import styles from './index.module.scss';
type Props = {
readonly plan: SubscriptionPlan;
readonly onSelect: () => void;
readonly buttonProps?: Partial<React.ComponentProps<typeof Button>>;
};
function PlanCardItem({ plan, onSelect, buttonProps }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.create_tenant' });
const { tenants } = useContext(TenantsContext);
const { stripeProducts, id: planId, name: planName } = plan;
const basePrice = useMemo(
() => stripeProducts.find(({ type }) => type === 'flat')?.price.unitAmountDecimal ?? 0,
[stripeProducts]
);
const isFreePlan = planId === ReservedPlanId.Free;
const isFreeTenantExceeded = useMemo(
() =>
/** Should not block admin tenant owners from creating more than three tenants */
!tenants.some(({ id }) => id === adminTenantId) &&
tenants.filter(({ planId }) => planId === ReservedPlanId.Free).length >= maxFreeTenantLimit,
[tenants]
);
return (
<div className={styles.container}>
<div className={styles.planInfo}>
<div className={styles.title}>
<PlanName name={planName} />
</div>
<div className={styles.priceInfo}>
<div className={styles.priceLabel}>{t('base_price')}</div>
<div className={styles.price}>
${t('monthly_price', { value: Number(basePrice) / 100 })}
</div>
</div>
<div className={styles.description}>
<PlanDescription planId={planId} />
</div>
</div>
<div className={styles.content}>
<FeaturedPlanContent planId={planId} />
{isFreePlan && isFreeTenantExceeded && (
<div className={classNames(styles.tip, styles.exceedFreeTenantsTip)}>
{t('free_tenants_limit', { count: maxFreeTenantLimit })}
</div>
)}
{!isFreePlan && (
<div className={styles.tip}>
<TextLink
isTrailingIcon
href={pricingLink}
targetBlank="noopener"
icon={<ArrowRight className={styles.linkIcon} />}
className={styles.link}
>
<DynamicT forKey="upsell.create_tenant.view_all_features" />
</TextLink>
</div>
)}
<Button
title={
<DangerousRaw>
<Trans components={{ name: <PlanName name={planName} /> }}>{t('select_plan')}</Trans>
</DangerousRaw>
}
type={isFreePlan ? 'outline' : 'primary'}
size="large"
onClick={onSelect}
{...buttonProps}
disabled={(isFreePlan && isFreeTenantExceeded) || buttonProps?.disabled}
/>
</div>
{planId === ReservedPlanId.Pro && (
<div className={styles.mostPopularTag}>{t('most_popular')}</div>
)}
</div>
);
}
export default PlanCardItem;

View file

@ -3,8 +3,7 @@ import classNames from 'classnames';
import Failed from '@/assets/icons/failed.svg?react'; import Failed from '@/assets/icons/failed.svg?react';
import Success from '@/assets/icons/success.svg?react'; import Success from '@/assets/icons/success.svg?react';
import styles from '../../PlanCardItem/FeaturedPlanContent/index.module.scss'; import styles from './index.module.scss';
import useFeaturedSkuContent from './use-featured-sku-content'; import useFeaturedSkuContent from './use-featured-sku-content';
type Props = { type Props = {

View file

@ -14,9 +14,8 @@ import DangerousRaw from '@/ds-components/DangerousRaw';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import styles from '../PlanCardItem/index.module.scss';
import FeaturedSkuContent from './FeaturedSkuContent'; import FeaturedSkuContent from './FeaturedSkuContent';
import styles from './FeaturedSkuContent/index.module.scss';
type Props = { type Props = {
readonly sku: LogtoSkuResponse; readonly sku: LogtoSkuResponse;

View file

@ -8,20 +8,16 @@ import { useCloudApi, toastResponseError } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse, type LogtoSkuResponse } from '@/cloud/types/router'; import { type TenantResponse, type LogtoSkuResponse } from '@/cloud/types/router';
import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils'; import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
import { pricingLink } from '@/consts'; import { pricingLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import DangerousRaw from '@/ds-components/DangerousRaw'; import DangerousRaw from '@/ds-components/DangerousRaw';
import ModalLayout from '@/ds-components/ModalLayout'; import ModalLayout from '@/ds-components/ModalLayout';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import useLogtoSkus from '@/hooks/use-logto-skus'; import useLogtoSkus from '@/hooks/use-logto-skus';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
import modalStyles from '@/scss/modal.module.scss'; import modalStyles from '@/scss/modal.module.scss';
import { type SubscriptionPlan } from '@/types/subscriptions'; import { pickupFeaturedLogtoSkus } from '@/utils/subscription';
import { pickupFeaturedPlans, pickupFeaturedLogtoSkus } from '@/utils/subscription';
import { type CreateTenantData } from '../types'; import { type CreateTenantData } from '../types';
import PlanCardItem from './PlanCardItem';
import SkuCardItem from './SkuCardItem'; import SkuCardItem from './SkuCardItem';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -31,44 +27,20 @@ type Props = {
}; };
function SelectTenantPlanModal({ tenantData, onClose }: Props) { function SelectTenantPlanModal({ tenantData, onClose }: Props) {
const [processingPlanId, setProcessingPlanId] = useState<string>();
const [processingSkuId, setProcessingSkuId] = useState<string>(); const [processingSkuId, setProcessingSkuId] = useState<string>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: subscriptionPlans } = useSubscriptionPlans();
const { data: logtoSkus } = useLogtoSkus(); const { data: logtoSkus } = useLogtoSkus();
const { subscribe } = useSubscribe(); const { subscribe } = useSubscribe();
const cloudApi = useCloudApi({ hideErrorToast: true }); const cloudApi = useCloudApi({ hideErrorToast: true });
const reservedPlans = conditional(subscriptionPlans && pickupFeaturedPlans(subscriptionPlans));
const reservedBasicLogtoSkus = conditional(logtoSkus && pickupFeaturedLogtoSkus(logtoSkus)); const reservedBasicLogtoSkus = conditional(logtoSkus && pickupFeaturedLogtoSkus(logtoSkus));
if (!reservedPlans || !reservedBasicLogtoSkus || !tenantData) { if (!reservedBasicLogtoSkus || !tenantData) {
return null; return null;
} }
const handleSelectPlan = async (plan: SubscriptionPlan) => {
const { id: planId } = plan;
try {
setProcessingPlanId(planId);
if (planId === ReservedPlanId.Free) {
const { name, tag, regionName } = tenantData;
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag, regionName } });
reportToGoogle(GtagConversionId.CreateProductionTenant, { transactionId: newTenant.id });
onClose(newTenant);
return;
}
await subscribe({ planId, tenantData });
} catch (error: unknown) {
void toastResponseError(error);
} finally {
setProcessingPlanId(undefined);
}
};
const handleSelectSku = async (logtoSku: LogtoSkuResponse) => { const handleSelectSku = async (logtoSku: LogtoSkuResponse) => {
const { id: skuId } = logtoSku; const { id: skuId } = logtoSku;
try { try {
@ -113,33 +85,19 @@ function SelectTenantPlanModal({ tenantData, onClose }: Props) {
onClose={onClose} onClose={onClose}
> >
<div className={styles.container}> <div className={styles.container}>
{isDevFeaturesEnabled {reservedBasicLogtoSkus.map((logtoSku) => (
? reservedBasicLogtoSkus.map((logtoSku) => ( <SkuCardItem
<SkuCardItem key={logtoSku.id}
key={logtoSku.id} sku={logtoSku}
sku={logtoSku} buttonProps={{
buttonProps={{ isLoading: processingSkuId === logtoSku.id,
isLoading: processingSkuId === logtoSku.id, disabled: Boolean(processingSkuId),
disabled: Boolean(processingSkuId), }}
}} onSelect={() => {
onSelect={() => { void handleSelectSku(logtoSku);
void handleSelectSku(logtoSku); }}
}} />
/> ))}
))
: reservedPlans.map((plan) => (
<PlanCardItem
key={plan.id}
plan={plan}
buttonProps={{
isLoading: processingPlanId === plan.id,
disabled: Boolean(processingPlanId),
}}
onSelect={() => {
void handleSelectPlan(plan);
}}
/>
))}
</div> </div>
</ModalLayout> </ModalLayout>
</Modal> </Modal>

View file

@ -2,7 +2,7 @@ import { ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import { useContext } from 'react'; import { useContext } from 'react';
import { isCloud, isDevFeaturesEnabled } 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';
@ -80,11 +80,11 @@ type CombinedAddOnAndFeatureTagProps = {
export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProps) { export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProps) {
const { hasAddOnTag, className, paywall } = props; const { hasAddOnTag, className, paywall } = props;
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isAddOnAvailable },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
// Show the "Add-on" tag for Pro plan when dev features enabled. // Show the "Add-on" tag for Pro plan when dev features enabled.
if (hasAddOnTag && isDevFeaturesEnabled && isCloud && planId === ReservedPlanId.Pro) { if (hasAddOnTag && isAddOnAvailable && isCloud && planId === ReservedPlanId.Pro) {
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>
); );

View file

@ -84,7 +84,7 @@ function MauExceededModal() {
</Trans> </Trans>
</InlineNotification> </InlineNotification>
<FormField title="subscription.plan_usage"> <FormField title="subscription.plan_usage">
<PlanUsage currentSubscription={currentSubscription} currentPlan={currentPlan} /> <PlanUsage />
</FormField> </FormField>
</ModalLayout> </ModalLayout>
</ReactModal> </ReactModal>

View file

@ -4,6 +4,7 @@ import classNames from 'classnames';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import Tip from '@/assets/icons/tip.svg?react'; import Tip from '@/assets/icons/tip.svg?react';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import IconButton from '@/ds-components/IconButton'; import IconButton from '@/ds-components/IconButton';
import Tag from '@/ds-components/Tag'; import Tag from '@/ds-components/Tag';
@ -50,7 +51,7 @@ function PlanUsageCard({
content={ content={
<Trans <Trans
components={{ components={{
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t(tooltipKey, { {t(tooltipKey, {

View file

@ -4,12 +4,10 @@ import classNames from 'classnames';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import { isDevFeaturesEnabled } from '@/consts/env';
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 { type SubscriptionPlan } from '@/types/subscriptions';
import { formatPeriod } from '@/utils/subscription'; import { formatPeriod } from '@/utils/subscription';
import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard'; import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard';
@ -17,14 +15,10 @@ import styles from './index.module.scss';
import { usageKeys, usageKeyPriceMap, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils'; import { usageKeys, usageKeyPriceMap, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils';
type Props = { type Props = {
/** @deprecated */
readonly currentSubscription: Subscription;
/** @deprecated */
readonly currentPlan: SubscriptionPlan;
readonly periodicUsage?: NewSubscriptionPeriodicUsage; readonly periodicUsage?: NewSubscriptionPeriodicUsage;
}; };
function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodicUsage }: Props) { function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
const { const {
currentSubscriptionQuota, currentSubscriptionQuota,
currentSubscriptionUsage, currentSubscriptionUsage,
@ -32,9 +26,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext); const { currentTenant } = useContext(TenantsContext);
const { currentPeriodStart, currentPeriodEnd } = isDevFeaturesEnabled const { currentPeriodStart, currentPeriodEnd } = currentSubscriptionFromNewPricingModel;
? currentSubscriptionFromNewPricingModel
: currentSubscription;
const periodicUsage = useMemo( const periodicUsage = useMemo(
() => () =>
@ -52,13 +44,6 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
return null; return null;
} }
const [activeUsers, mauLimit] = [
periodicUsage.mauLimit,
isDevFeaturesEnabled ? currentSubscriptionQuota.mauLimit : currentPlan.quota.mauLimit,
];
const mauUsagePercent = conditional(mauLimit && activeUsers / mauLimit);
const usages: PlanUsageCardProps[] = usageKeys const usages: PlanUsageCardProps[] = usageKeys
// Show all usages for Pro plan and only show MAU and token usage for Free plan // Show all usages for Pro plan and only show MAU and token usage for Free plan
.filter( .filter(
@ -85,7 +70,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
), ),
})); }));
return isDevFeaturesEnabled ? ( return (
<div> <div>
<div className={classNames(styles.planCycle, styles.planCycleNewPricingModel)}> <div className={classNames(styles.planCycle, styles.planCycleNewPricingModel)}>
<DynamicT <DynamicT
@ -114,39 +99,6 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
))} ))}
</div> </div>
</div> </div>
) : (
<div className={styles.container}>
<div className={styles.usage}>
{`${activeUsers} / `}
{mauLimit === null ? (
<DynamicT forKey="subscription.quota_table.unlimited" />
) : (
mauLimit.toLocaleString()
)}
{' MAU'}
{mauUsagePercent && ` (${(mauUsagePercent * 100).toFixed(2)}%)`}
</div>
<div className={styles.planCycle}>
<DynamicT
forKey="subscription.plan_cycle"
interpolation={{
period: formatPeriod({
periodStart: currentPeriodStart,
periodEnd: currentPeriodEnd,
}),
renewDate: dayjs(currentPeriodEnd).add(1, 'day').format('MMM D, YYYY'),
}}
/>
</div>
{mauUsagePercent && (
<div className={styles.usageBar}>
<div
className={classNames(styles.usageBarInner, mauUsagePercent >= 1 && styles.overuse)}
style={{ width: `${Math.min(mauUsagePercent, 1) * 100}%` }}
/>
</div>
)}
</div>
); );
} }

View file

@ -1,17 +1,13 @@
import { type TenantResponse } from '@/cloud/types/router'; import { type TenantResponse } from '@/cloud/types/router';
import { isDevFeaturesEnabled } from '@/consts/env';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag'; import Tag from '@/ds-components/Tag';
import { type SubscriptionPlan } from '@/types/subscriptions';
type Props = { type Props = {
readonly tenantData: TenantResponse; readonly tenantData: TenantResponse;
/** @deprecated */
readonly tenantSubscriptionPlan: SubscriptionPlan;
readonly className?: string; readonly className?: string;
}; };
function TenantStatusTag({ tenantData, tenantSubscriptionPlan, className }: Props) { function TenantStatusTag({ tenantData, className }: Props) {
const { usage, quota, openInvoices, isSuspended } = tenantData; const { usage, quota, openInvoices, isSuspended } = tenantData;
/** /**
@ -39,7 +35,7 @@ function TenantStatusTag({ tenantData, tenantSubscriptionPlan, className }: Prop
const { activeUsers } = usage; const { activeUsers } = usage;
const mauLimit = isDevFeaturesEnabled ? quota.mauLimit : tenantSubscriptionPlan.quota.mauLimit; const { mauLimit } = quota;
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit; const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;

View file

@ -1,5 +1,4 @@
import { TenantTag } from '@logto/schemas'; import { TenantTag } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
@ -7,7 +6,6 @@ import Tick from '@/assets/icons/tick.svg?react';
import { type TenantResponse } from '@/cloud/types/router'; import { type TenantResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import TenantEnvTag from '@/components/TenantEnvTag'; import TenantEnvTag from '@/components/TenantEnvTag';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { DropdownItem } from '@/ds-components/Dropdown'; import { DropdownItem } from '@/ds-components/Dropdown';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
@ -44,17 +42,13 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
<div className={styles.meta}> <div className={styles.meta}>
<div className={styles.name}>{name}</div> <div className={styles.name}>{name}</div>
<TenantEnvTag tag={tag} /> <TenantEnvTag tag={tag} />
<TenantStatusTag <TenantStatusTag tenantData={tenantData} className={styles.statusTag} />
tenantData={tenantData}
tenantSubscriptionPlan={tenantSubscriptionPlan}
className={styles.statusTag}
/>
</div> </div>
<div className={styles.planName}> <div className={styles.planName}>
{tag === TenantTag.Development ? ( {tag === TenantTag.Development ? (
<DynamicT forKey="subscription.no_subscription" /> <DynamicT forKey="subscription.no_subscription" />
) : ( ) : (
<PlanName skuId={conditional(isDevFeaturesEnabled && planId)} name={currentPlan.name} /> <PlanName skuId={planId} name={currentPlan.name} />
)} )}
</div> </div>
</div> </div>

View file

@ -36,3 +36,6 @@ export const organizationJit = Object.freeze({
'/docs/recipes/organizations/just-in-time-provisioning/#enterprise-sso-provisioning', '/docs/recipes/organizations/just-in-time-provisioning/#enterprise-sso-provisioning',
emailDomain: '/docs/recipes/organizations/just-in-time-provisioning/#email-domain-provisioning', emailDomain: '/docs/recipes/organizations/just-in-time-provisioning/#email-domain-provisioning',
}); });
export const addOnPricingExplanationLink =
'https://blog.logto.io/pricing-add-on-a-simple-explanation/';

View file

@ -11,31 +11,6 @@ export const ticketSupportResponseTimeMap: Record<string, number> = {
[ReservedPlanId.Pro]: 48, [ReservedPlanId.Pro]: 48,
}; };
/**
* Define the order of quota items in the downgrade plan notification modal and not eligible for downgrade plan modal.
*/
export const planQuotaItemOrder: Array<keyof SubscriptionPlanQuota> = [
'mauLimit',
'tokenLimit',
'applicationsLimit',
'machineToMachineLimit',
'thirdPartyApplicationsLimit',
'resourcesLimit',
'scopesPerResourceLimit',
'customDomainEnabled',
'omniSignInEnabled',
'socialConnectorsLimit',
'mfaEnabled',
'ssoEnabled',
'rolesLimit',
'machineToMachineRolesLimit',
'scopesPerRoleLimit',
'organizationsEnabled',
'auditLogsRetentionDays',
'hooksLimit',
'ticketSupportResponseTime',
];
/** /**
* Define the order of quota items in the downgrade plan notification modal and not eligible for downgrade plan modal. * Define the order of quota items in the downgrade plan notification modal and not eligible for downgrade plan modal.
*/ */

View file

@ -1,131 +1,6 @@
import { type TFuncKey } from 'i18next'; import { type TFuncKey } from 'i18next';
import { type LogtoSkuQuota } from '@/types/skus'; import { type LogtoSkuQuota } from '@/types/skus';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
/** @deprecated */
export const quotaItemPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.name',
tokenLimit: 'token_limit.name',
applicationsLimit: 'applications_limit.name',
machineToMachineLimit: 'machine_to_machine_limit.name',
thirdPartyApplicationsLimit: 'third_party_applications_limit.name',
resourcesLimit: 'resources_limit.name',
scopesPerResourceLimit: 'scopes_per_resource_limit.name',
customDomainEnabled: 'custom_domain_enabled.name',
omniSignInEnabled: 'omni_sign_in_enabled.name',
socialConnectorsLimit: 'social_connectors_limit.name',
standardConnectorsLimit: 'standard_connectors_limit.name',
rolesLimit: 'roles_limit.name',
machineToMachineRolesLimit: 'machine_to_machine_roles_limit.name',
scopesPerRoleLimit: 'scopes_per_role_limit.name',
hooksLimit: 'hooks_limit.name',
auditLogsRetentionDays: 'audit_logs_retention_days.name',
ticketSupportResponseTime: 'email_ticket_support.name',
mfaEnabled: 'mfa_enabled.name',
organizationsEnabled: 'organizations_enabled.name',
ssoEnabled: 'sso_enabled.name',
tenantMembersLimit: 'tenant_members_limit.name',
customJwtEnabled: 'custom_jwt_enabled.name',
subjectTokenEnabled: 'impersonation_enabled.name',
bringYourUiEnabled: 'bring_your_ui_enabled.name',
};
/** @deprecated */
export const quotaItemUnlimitedPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.unlimited',
tokenLimit: 'token_limit.unlimited',
applicationsLimit: 'applications_limit.unlimited',
machineToMachineLimit: 'machine_to_machine_limit.unlimited',
thirdPartyApplicationsLimit: 'third_party_applications_limit.unlimited',
resourcesLimit: 'resources_limit.unlimited',
scopesPerResourceLimit: 'scopes_per_resource_limit.unlimited',
customDomainEnabled: 'custom_domain_enabled.unlimited',
omniSignInEnabled: 'omni_sign_in_enabled.unlimited',
socialConnectorsLimit: 'social_connectors_limit.unlimited',
standardConnectorsLimit: 'standard_connectors_limit.unlimited',
rolesLimit: 'roles_limit.unlimited',
machineToMachineRolesLimit: 'machine_to_machine_roles_limit.unlimited',
scopesPerRoleLimit: 'scopes_per_role_limit.unlimited',
hooksLimit: 'hooks_limit.unlimited',
auditLogsRetentionDays: 'audit_logs_retention_days.unlimited',
ticketSupportResponseTime: 'email_ticket_support.unlimited',
mfaEnabled: 'mfa_enabled.unlimited',
organizationsEnabled: 'organizations_enabled.unlimited',
ssoEnabled: 'sso_enabled.unlimited',
tenantMembersLimit: 'tenant_members_limit.unlimited',
customJwtEnabled: 'custom_jwt_enabled.unlimited',
subjectTokenEnabled: 'impersonation_enabled.unlimited',
bringYourUiEnabled: 'bring_your_ui_enabled.unlimited',
};
/** @deprecated */
export const quotaItemLimitedPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.limited',
tokenLimit: 'token_limit.limited',
applicationsLimit: 'applications_limit.limited',
machineToMachineLimit: 'machine_to_machine_limit.limited',
thirdPartyApplicationsLimit: 'third_party_applications_limit.limited',
resourcesLimit: 'resources_limit.limited',
scopesPerResourceLimit: 'scopes_per_resource_limit.limited',
customDomainEnabled: 'custom_domain_enabled.limited',
omniSignInEnabled: 'omni_sign_in_enabled.limited',
socialConnectorsLimit: 'social_connectors_limit.limited',
standardConnectorsLimit: 'standard_connectors_limit.limited',
rolesLimit: 'roles_limit.limited',
machineToMachineRolesLimit: 'machine_to_machine_roles_limit.limited',
scopesPerRoleLimit: 'scopes_per_role_limit.limited',
hooksLimit: 'hooks_limit.limited',
auditLogsRetentionDays: 'audit_logs_retention_days.limited',
ticketSupportResponseTime: 'email_ticket_support.limited',
mfaEnabled: 'mfa_enabled.limited',
organizationsEnabled: 'organizations_enabled.limited',
ssoEnabled: 'sso_enabled.limited',
tenantMembersLimit: 'tenant_members_limit.limited',
customJwtEnabled: 'custom_jwt_enabled.limited',
subjectTokenEnabled: 'impersonation_enabled.limited',
bringYourUiEnabled: 'bring_your_ui_enabled.limited',
};
/** @deprecated */
export const quotaItemNotEligiblePhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.not_eligible',
tokenLimit: 'token_limit.not_eligible',
applicationsLimit: 'applications_limit.not_eligible',
machineToMachineLimit: 'machine_to_machine_limit.not_eligible',
thirdPartyApplicationsLimit: 'third_party_applications_limit.not_eligible',
resourcesLimit: 'resources_limit.not_eligible',
scopesPerResourceLimit: 'scopes_per_resource_limit.not_eligible',
customDomainEnabled: 'custom_domain_enabled.not_eligible',
omniSignInEnabled: 'omni_sign_in_enabled.not_eligible',
socialConnectorsLimit: 'social_connectors_limit.not_eligible',
standardConnectorsLimit: 'standard_connectors_limit.not_eligible',
rolesLimit: 'roles_limit.not_eligible',
machineToMachineRolesLimit: 'machine_to_machine_roles_limit.not_eligible',
scopesPerRoleLimit: 'scopes_per_role_limit.not_eligible',
hooksLimit: 'hooks_limit.not_eligible',
auditLogsRetentionDays: 'audit_logs_retention_days.not_eligible',
ticketSupportResponseTime: 'email_ticket_support.not_eligible',
mfaEnabled: 'mfa_enabled.not_eligible',
organizationsEnabled: 'organizations_enabled.not_eligible',
ssoEnabled: 'sso_enabled.not_eligible',
tenantMembersLimit: 'tenant_members_limit.not_eligible',
customJwtEnabled: 'custom_jwt_enabled.not_eligible',
subjectTokenEnabled: 'impersonation_enabled.not_eligible',
bringYourUiEnabled: 'bring_your_ui_enabled.not_eligible',
};
/* === for new pricing model === */ /* === for new pricing model === */
export const skuQuotaItemPhrasesMap: Record< export const skuQuotaItemPhrasesMap: Record<

View file

@ -1,65 +1,33 @@
import { isManagementApi } from '@logto/schemas';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import useSWR from 'swr';
import { type ApiResource } from '@/consts';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit, hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota'; } from '@/utils/quota';
const useApiResourcesUsage = () => { const useApiResourcesUsage = () => {
const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } = const { currentSubscriptionQuota, currentSubscriptionUsage } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
/**
* Note: we only need to fetch all resources when the user is in cloud environment.
* The oss version doesn't have the quota limit.
*/
const { data: allResources } = useSWR<ApiResource[]>(isCloud && 'api/resources');
const resourceCount = useMemo(
() =>
isDevFeaturesEnabled
? currentSubscriptionUsage.resourcesLimit
: allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0,
[allResources, currentSubscriptionUsage.resourcesLimit]
);
const hasReachedLimit = useMemo( const hasReachedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'resourcesLimit',
quotaKey: 'resourcesLimit', usage: currentSubscriptionUsage.resourcesLimit,
usage: currentSubscriptionUsage.resourcesLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
: hasReachedQuotaLimit({
quotaKey: 'resourcesLimit',
plan: currentPlan,
usage: resourceCount,
}),
[currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
); );
const hasSurpassedLimit = useMemo( const hasSurpassedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasSurpassedSubscriptionQuotaLimit({
? hasSurpassedSubscriptionQuotaLimit({ quotaKey: 'resourcesLimit',
quotaKey: 'resourcesLimit', usage: currentSubscriptionUsage.resourcesLimit,
usage: currentSubscriptionUsage.resourcesLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
: hasSurpassedQuotaLimit({
quotaKey: 'resourcesLimit',
plan: currentPlan,
usage: resourceCount,
}),
[currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
); );
return { return {

View file

@ -20,7 +20,7 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestTimeout } from '@/consts'; import { requestTimeout } from '@/consts';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { AppDataContext } from '@/contexts/AppDataProvider'; import { AppDataContext } from '@/contexts/AppDataProvider';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -155,7 +155,6 @@ export const useStaticApi = ({
async (request, _options, response) => { async (request, _options, response) => {
if ( if (
isCloud && isCloud &&
isDevFeaturesEnabled &&
isAuthenticated && isAuthenticated &&
['POST', 'PUT', 'DELETE'].includes(request.method) && ['POST', 'PUT', 'DELETE'].includes(request.method) &&
response.status >= 200 && response.status >= 200 &&

View file

@ -1,125 +1,53 @@
import { type Application, ApplicationType } from '@logto/schemas';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import useSWR from 'swr';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit, hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota'; } from '@/utils/quota';
const useApplicationsUsage = () => { const useApplicationsUsage = () => {
const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } = const { currentSubscriptionQuota, currentSubscriptionUsage } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
/**
* Note: we only need to fetch all applications when the user is in cloud environment.
* The oss version doesn't have the quota limit.
*/
const { data: allApplications } = useSWR<Application[]>(isCloud && 'api/applications');
const m2mAppCount = useMemo(
() =>
isDevFeaturesEnabled
? currentSubscriptionUsage.machineToMachineLimit
: allApplications?.filter(({ type }) => type === ApplicationType.MachineToMachine).length ??
0,
[allApplications, currentSubscriptionUsage.machineToMachineLimit]
);
const thirdPartyAppCount = useMemo(
() =>
isDevFeaturesEnabled
? currentSubscriptionUsage.thirdPartyApplicationsLimit
: allApplications?.filter(({ isThirdParty }) => isThirdParty).length ?? 0,
[allApplications, currentSubscriptionUsage.thirdPartyApplicationsLimit]
);
const hasMachineToMachineAppsReachedLimit = useMemo( const hasMachineToMachineAppsReachedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'machineToMachineLimit',
quotaKey: 'machineToMachineLimit', usage: currentSubscriptionUsage.machineToMachineLimit,
usage: currentSubscriptionUsage.machineToMachineLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
: hasReachedQuotaLimit({
quotaKey: 'machineToMachineLimit',
plan: currentPlan,
usage: m2mAppCount,
}),
[
currentPlan,
m2mAppCount,
currentSubscriptionUsage.machineToMachineLimit,
currentSubscriptionQuota,
]
); );
const hasMachineToMachineAppsSurpassedLimit = useMemo( const hasMachineToMachineAppsSurpassedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasSurpassedSubscriptionQuotaLimit({
? hasSurpassedSubscriptionQuotaLimit({ quotaKey: 'machineToMachineLimit',
quotaKey: 'machineToMachineLimit', usage: currentSubscriptionUsage.machineToMachineLimit,
usage: currentSubscriptionUsage.machineToMachineLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
: hasSurpassedQuotaLimit({
quotaKey: 'machineToMachineLimit',
plan: currentPlan,
usage: m2mAppCount,
}),
[
currentPlan,
m2mAppCount,
currentSubscriptionUsage.machineToMachineLimit,
currentSubscriptionQuota,
]
); );
const hasThirdPartyAppsReachedLimit = useMemo( const hasThirdPartyAppsReachedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'thirdPartyApplicationsLimit',
quotaKey: 'thirdPartyApplicationsLimit', usage: currentSubscriptionUsage.thirdPartyApplicationsLimit,
usage: currentSubscriptionUsage.thirdPartyApplicationsLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionUsage.thirdPartyApplicationsLimit, currentSubscriptionQuota]
: hasReachedQuotaLimit({
quotaKey: 'thirdPartyApplicationsLimit',
plan: currentPlan,
usage: thirdPartyAppCount,
}),
[
currentPlan,
thirdPartyAppCount,
currentSubscriptionUsage.thirdPartyApplicationsLimit,
currentSubscriptionQuota,
]
); );
const hasAppsReachedLimit = useMemo( const hasAppsReachedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'applicationsLimit',
quotaKey: 'applicationsLimit', usage: currentSubscriptionUsage.applicationsLimit,
usage: currentSubscriptionUsage.applicationsLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, }),
}) [currentSubscriptionUsage.applicationsLimit, currentSubscriptionQuota]
: hasReachedQuotaLimit({
quotaKey: 'applicationsLimit',
plan: currentPlan,
usage: allApplications?.length ?? 0,
}),
[
allApplications?.length,
currentPlan,
currentSubscriptionUsage.applicationsLimit,
currentSubscriptionQuota,
]
); );
return { return {

View file

@ -1,5 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import dayjs from 'dayjs';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@ -7,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import { toastResponseError, useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { toastResponseError, useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type CreateTenantData } from '@/components/CreateTenantModal/types'; import { type CreateTenantData } from '@/components/CreateTenantModal/types';
import { isDevFeaturesEnabled } from '@/consts/env';
import { checkoutStateQueryKey } from '@/consts/subscriptions'; import { checkoutStateQueryKey } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { GlobalRoute, TenantsContext } from '@/contexts/TenantsProvider'; import { GlobalRoute, TenantsContext } from '@/contexts/TenantsProvider';
@ -101,38 +98,20 @@ const useSubscribe = () => {
}, },
}); });
// Should not use hard-coded plan update here, need to update the tenant's subscription data with response from corresponding API. const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', {
if (isDevFeaturesEnabled) { params: {
const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', { tenantId,
params: {
tenantId,
},
});
mutateSubscriptionQuotaAndUsages();
onCurrentSubscriptionUpdated(subscription);
const { id, ...rest } = subscription;
updateTenant(tenantId, {
planId: rest.planId,
subscription: rest,
});
return;
}
/**
* Note: need to update the tenant's subscription cache data,
* since the cancel subscription flow will not redirect to the stripe payment page.
*/
updateTenant(tenantId, {
planId: ReservedPlanId.Free,
subscription: {
status: 'active',
planId: ReservedPlanId.Free,
currentPeriodStart: dayjs().toDate(),
currentPeriodEnd: dayjs().add(1, 'month').toDate(),
}, },
}); });
mutateSubscriptionQuotaAndUsages();
onCurrentSubscriptionUpdated(subscription);
const { id, ...rest } = subscription;
updateTenant(tenantId, {
planId: rest.planId,
subscription: rest,
});
}; };
const visitManagePaymentPage = async (tenantId: string) => { const visitManagePaymentPage = async (tenantId: string) => {

View file

@ -8,7 +8,6 @@ import ReactModal from 'react-modal';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
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';
@ -17,7 +16,7 @@ import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss'; import modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form'; import { trySubmitSafe } from '@/utils/form';
import { hasReachedQuotaLimit, hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = { type Props = {
readonly resourceId: string; readonly resourceId: string;
@ -59,17 +58,11 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
}) })
); );
const isScopesPerResourceReachLimit = isDevFeaturesEnabled const isScopesPerResourceReachLimit = hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'scopesPerResourceLimit',
quotaKey: 'scopesPerResourceLimit', usage: currentSubscriptionResourceScopeUsage[resourceId] ?? 0,
usage: currentSubscriptionResourceScopeUsage[resourceId] ?? 0, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, });
})
: hasReachedQuotaLimit({
quotaKey: 'scopesPerResourceLimit',
plan: currentPlan,
usage: totalResourceCount,
});
return ( return (
<ReactModal <ReactModal
@ -98,10 +91,7 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
}} }}
> >
{t('upsell.paywall.scopes_per_resource', { {t('upsell.paywall.scopes_per_resource', {
count: count: currentSubscriptionQuota.scopesPerResourceLimit ?? 0,
(isDevFeaturesEnabled
? currentSubscriptionQuota.scopesPerResourceLimit
: currentPlan.quota.scopesPerResourceLimit) ?? 0,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>

View file

@ -6,7 +6,7 @@ import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { resourceAddOnUnitPrice } from '@/consts/subscriptions'; import { resourceAddOnUnitPrice } 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';
@ -25,7 +25,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentPlan, currentPlan,
currentSubscription: { planId }, currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionUsage: { resourcesLimit }, currentSubscriptionUsage: { resourcesLimit },
currentSku, currentSku,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
@ -40,7 +40,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
/** /**
* We don't guard API resources quota limit for paid plan, since it's an add-on feature * We don't guard API resources quota limit for paid plan, since it's an add-on feature
*/ */
(isDevFeaturesEnabled ? currentSku.id : currentPlan.id) === ReservedPlanId.Free currentSku.id === ReservedPlanId.Free
) { ) {
return ( return (
<QuotaGuardFooter> <QuotaGuardFooter>
@ -51,7 +51,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
}} }}
> >
{t('upsell.paywall.resources', { {t('upsell.paywall.resources', {
count: (isDevFeaturesEnabled ? resourcesLimit : currentPlan.quota.resourcesLimit) ?? 0, count: resourcesLimit,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>
@ -59,7 +59,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
} }
if ( if (
isDevFeaturesEnabled && isAddOnAvailable &&
hasReachedLimit && hasReachedLimit &&
planId === ReservedPlanId.Pro && planId === ReservedPlanId.Pro &&
!apiResourceUpsellNoticeAcknowledged !apiResourceUpsellNoticeAcknowledged
@ -76,7 +76,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
<Trans <Trans
components={{ components={{
span: <span className={styles.strong} />, span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('upsell.add_on.footer.api_resource', { {t('upsell.add_on.footer.api_resource', {

View file

@ -7,7 +7,6 @@ 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 { isDevFeaturesEnabled } from '@/consts/env';
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';
@ -31,7 +30,7 @@ type Props = {
function CreateForm({ onClose }: Props) { function CreateForm({ onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isAddOnAvailable },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { const {
@ -67,10 +66,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( paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro hasAddOnTag={isAddOnAvailable}
)}
hasAddOnTag={isDevFeaturesEnabled}
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />} footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
onClose={onClose} onClose={onClose}
> >

View file

@ -13,7 +13,7 @@ import useSWRImmutable from 'swr/immutable';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled, isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button, { type Props as ButtonProps } from '@/ds-components/Button'; import Button, { type Props as ButtonProps } from '@/ds-components/Button';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
@ -36,8 +36,6 @@ type Props = {
readonly onCreateSuccess?: (createdApp: Application) => void; readonly onCreateSuccess?: (createdApp: Application) => void;
}; };
// TODO: refactor this component to reduce complexity
// eslint-disable-next-line complexity
function ProtectedAppForm({ function ProtectedAppForm({
className, className,
buttonAlignment = 'right', buttonAlignment = 'right',
@ -49,7 +47,7 @@ function ProtectedAppForm({
}: Props) { }: Props) {
const { data } = useSWRImmutable<ProtectedAppsDomainConfig>(isCloud && 'api/systems/application'); const { data } = useSWRImmutable<ProtectedAppsDomainConfig>(isCloud && 'api/systems/application');
const { const {
currentPlan: { name: planName, quota }, currentPlan: { name: planName },
currentSku, currentSku,
currentSubscriptionQuota, currentSubscriptionQuota,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
@ -212,10 +210,7 @@ function ProtectedAppForm({
}} }}
> >
{t('upsell.paywall.applications', { {t('upsell.paywall.applications', {
count: count: currentSubscriptionQuota.applicationsLimit ?? 0,
(isDevFeaturesEnabled
? currentSubscriptionQuota.applicationsLimit
: quota.applicationsLimit) ?? 0,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>

View file

@ -11,7 +11,6 @@ import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import AppLoading from '@/components/AppLoading'; import AppLoading from '@/components/AppLoading';
import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils'; import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import { isDevFeaturesEnabled } from '@/consts/env';
import { checkoutStateQueryKey } from '@/consts/subscriptions'; import { checkoutStateQueryKey } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -84,43 +83,31 @@ function CheckoutSuccessCallback() {
const isCheckoutSuccessful = const isCheckoutSuccessful =
checkoutTenantId && checkoutTenantId &&
stripeCheckoutSession.status === 'complete' && stripeCheckoutSession.status === 'complete' &&
(isDevFeaturesEnabled !isLoadingLogtoSkus &&
? !isLoadingLogtoSkus && checkoutSkuId === tenantSubscription?.planId checkoutSkuId === tenantSubscription?.planId;
: !isLoadingPlans && checkoutPlanId === tenantSubscription?.planId);
useEffect(() => { useEffect(() => {
if (isCheckoutSuccessful) { if (isCheckoutSuccessful) {
clearLocalCheckoutSession(); clearLocalCheckoutSession();
if (isDevFeaturesEnabled) { const checkoutSku = logtoSkus?.find((sku) => sku.id === checkoutPlanId);
const checkoutSku = logtoSkus?.find((sku) => sku.id === checkoutPlanId); if (checkoutSku) {
if (checkoutSku) { toast.success(
toast.success( <Trans
<Trans components={{
components={{ name: (
name: ( <PlanName
<PlanName skuId={checkoutSku.id}
skuId={checkoutSku.id} // Generally `checkoutPlanId` and a properly setup of SKU `name` should not be null, we still need to handle the edge case to make the type inference happy.
// Generally `checkoutPlanId` and a properly setup of SKU `name` should not be null, we still need to handle the edge case to make the type inference happy. // Also `name` will be deprecated in the future once the new pricing model is ready.
// Also `name` will be deprecated in the future once the new pricing model is ready. name={checkoutPlanId ?? checkoutSku.name ?? checkoutSku.id}
name={checkoutPlanId ?? checkoutSku.name ?? checkoutSku.id} />
/> ),
), }}
}} >
> {t(isDowngrade ? 'downgrade_success' : 'upgrade_success')}
{t(isDowngrade ? 'downgrade_success' : 'upgrade_success')} </Trans>
</Trans> );
);
}
} else {
const checkoutPlan = subscriptionPlans?.find((plan) => plan.id === checkoutPlanId);
if (checkoutPlan) {
toast.success(
<Trans components={{ name: <PlanName name={checkoutPlan.name} /> }}>
{t(isDowngrade ? 'downgrade_success' : 'upgrade_success')}
</Trans>
);
}
} }
onCurrentSubscriptionUpdated(tenantSubscription); onCurrentSubscriptionUpdated(tenantSubscription);

View file

@ -3,7 +3,7 @@ import { useCallback, useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
@ -21,13 +21,9 @@ function CreateButton({ isDisabled, tokenType }: Props) {
const { show } = useConfirmModal(); const { show } = useConfirmModal();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext); const { currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const isCustomJwtEnabled = const isCustomJwtEnabled = !isCloud || currentSubscriptionQuota.customJwtEnabled;
!isCloud ||
(isDevFeaturesEnabled
? currentSubscriptionQuota.customJwtEnabled
: currentPlan.quota.customJwtEnabled);
const onCreateButtonClick = useCallback(async () => { const onCreateButtonClick = useCallback(async () => {
if (isCustomJwtEnabled) { if (isCustomJwtEnabled) {
@ -61,7 +57,7 @@ function CreateButton({ isDisabled, tokenType }: Props) {
<Button <Button
type="primary" type="primary"
title="jwt_claims.custom_jwt_create_button" title="jwt_claims.custom_jwt_create_button"
disabled={isDevFeaturesEnabled && isDisabled} disabled={isDisabled}
onClick={onCreateButtonClick} onClick={onCreateButtonClick}
/> />
); );

View file

@ -4,7 +4,7 @@ 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, isDevFeaturesEnabled } 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 CardTitle from '@/ds-components/CardTitle'; import CardTitle from '@/ds-components/CardTitle';
@ -22,12 +22,10 @@ function CustomizeJwt() {
const { isDevTenant } = useContext(TenantsContext); const { isDevTenant } = useContext(TenantsContext);
const { const {
currentPlan, currentSubscription: { planId, isAddOnAvailable },
currentSubscription: { planId },
currentSubscriptionQuota: { customJwtEnabled }, currentSubscriptionQuota: { customJwtEnabled },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const isCustomJwtEnabled = const isCustomJwtEnabled = !isCloud || customJwtEnabled;
!isCloud || (isDevFeaturesEnabled ? customJwtEnabled : currentPlan.quota.customJwtEnabled);
const showPaywall = planId === ReservedPlanId.Free; const showPaywall = planId === ReservedPlanId.Free;
@ -48,9 +46,7 @@ function CustomizeJwt() {
subtitle="jwt_claims.description" subtitle="jwt_claims.description"
className={styles.header} className={styles.header}
/> />
{isDevFeaturesEnabled && ( {isAddOnAvailable && <UpsellNotice isVisible={showPaywall} className={styles.inlineNotice} />}
<UpsellNotice isVisible={showPaywall} className={styles.inlineNotice} />
)}
<div className={styles.container}> <div className={styles.container}>
{isLoading && ( {isLoading && (
<> <>

View file

@ -17,7 +17,8 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import Skeleton from '@/components/CreateConnectorForm/Skeleton'; import Skeleton from '@/components/CreateConnectorForm/Skeleton';
import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/utils'; import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/utils';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { enterpriseSsoAddOnUnitPrice } from '@/consts/subscriptions'; import { enterpriseSsoAddOnUnitPrice } 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';
@ -49,8 +50,7 @@ const duplicateConnectorNameErrorCode = 'single_sign_on.duplicate_connector_name
function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) { function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentPlan, currentSubscription: { planId, isAddOnAvailable },
currentSubscription: { planId },
currentSubscriptionQuota, currentSubscriptionQuota,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { const {
@ -61,10 +61,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const isSsoEnabled = const isSsoEnabled =
!isCloud || !isCloud ||
(isDevFeaturesEnabled currentSubscriptionQuota.enterpriseSsoLimit === null ||
? currentSubscriptionQuota.enterpriseSsoLimit === null || currentSubscriptionQuota.enterpriseSsoLimit > 0;
currentSubscriptionQuota.enterpriseSsoLimit > 0
: currentPlan.quota.ssoEnabled);
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>( const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
'api/sso-connector-providers' 'api/sso-connector-providers'
@ -155,11 +153,11 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
<ModalLayout <ModalLayout
title="enterprise_sso.create_modal.title" title="enterprise_sso.create_modal.title"
paywall={conditional( paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro isAddOnAvailable && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)} )}
footer={ footer={
conditional( conditional(
isDevFeaturesEnabled && isAddOnAvailable &&
planId === ReservedPlanId.Pro && planId === ReservedPlanId.Pro &&
!enterpriseSsoUpsellNoticeAcknowledged && ( !enterpriseSsoUpsellNoticeAcknowledged && (
<AddOnNoticeFooter <AddOnNoticeFooter
@ -173,7 +171,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
<Trans <Trans
components={{ components={{
span: <span className={styles.strong} />, span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('upsell.add_on.footer.enterprise_sso', { {t('upsell.add_on.footer.enterprise_sso', {

View file

@ -11,7 +11,7 @@ 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, isDevFeaturesEnabled } 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 Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
@ -36,17 +36,16 @@ function EnterpriseSso() {
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 { isDevTenant } = useContext(TenantsContext);
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext); const {
currentSubscription: { isAddOnAvailable },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({ const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
page: 1, page: 1,
}); });
const isSsoEnabled = const isSsoEnabled = !isCloud || currentSubscriptionQuota.enterpriseSsoLimit !== 0;
!isCloud ||
(isDevFeaturesEnabled
? currentSubscriptionQuota.enterpriseSsoLimit !== 0
: currentPlan.quota.ssoEnabled);
const url = buildUrl('api/sso-connectors', { const url = buildUrl('api/sso-connectors', {
page: String(page), page: String(page),
@ -66,7 +65,7 @@ function EnterpriseSso() {
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro), paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
title: 'enterprise_sso.title', title: 'enterprise_sso.title',
subtitle: 'enterprise_sso.subtitle', subtitle: 'enterprise_sso.subtitle',
hasAddOnTag: isDevFeaturesEnabled, hasAddOnTag: isAddOnAvailable,
}} }}
pageMeta={{ titleKey: 'enterprise_sso.page_title' }} pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
createButton={conditional( createButton={conditional(

View file

@ -2,6 +2,7 @@ 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';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { mfaAddOnUnitPrice } from '@/consts/subscriptions'; import { mfaAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
@ -36,7 +37,7 @@ function UpsellNotice({ className }: Props) {
> >
<Trans <Trans
components={{ components={{
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('upsell.add_on.mfa_inline_notification', { {t('upsell.add_on.mfa_inline_notification', {

View file

@ -8,7 +8,7 @@ import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard'; import FormCard from '@/components/FormCard';
import InlineUpsell from '@/components/InlineUpsell'; import InlineUpsell from '@/components/InlineUpsell';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
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';
@ -33,11 +33,9 @@ type Props = {
}; };
function MfaForm({ data, onMfaUpdated }: Props) { function MfaForm({ data, onMfaUpdated }: Props) {
const { currentPlan, currentSubscriptionQuota, mutateSubscriptionQuotaAndUsages } = const { currentSubscriptionQuota, mutateSubscriptionQuotaAndUsages } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const isMfaDisabled = const isMfaDisabled = isCloud && !currentSubscriptionQuota.mfaEnabled;
isCloud &&
!(isDevFeaturesEnabled ? currentSubscriptionQuota.mfaEnabled : currentPlan.quota.mfaEnabled);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl(); const { getDocumentationUrl } = useDocumentationUrl();

View file

@ -3,7 +3,7 @@ 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, isDevFeaturesEnabled } 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 CardTitle from '@/ds-components/CardTitle'; import CardTitle from '@/ds-components/CardTitle';
@ -17,18 +17,17 @@ type Props = {
function PageWrapper({ children }: Props) { function PageWrapper({ children }: Props) {
const { isDevTenant } = useContext(TenantsContext); const { isDevTenant } = useContext(TenantsContext);
const { const {
currentPlan, currentSubscription: { isAddOnAvailable },
currentSubscriptionQuota: { mfaEnabled }, currentSubscriptionQuota: { mfaEnabled },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const isMfaEnabled = const isMfaEnabled = !isCloud || mfaEnabled;
!isCloud || (isDevFeaturesEnabled ? mfaEnabled : currentPlan.quota.mfaEnabled);
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((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)}
hasAddOnTag={isDevFeaturesEnabled} hasAddOnTag={isAddOnAvailable}
title="mfa.title" title="mfa.title"
subtitle="mfa.description" subtitle="mfa.description"
className={styles.cardTitle} className={styles.cardTitle}

View file

@ -9,7 +9,7 @@ import OrganizationEmpty from '@/assets/images/organization-empty.svg?react';
import Drawer from '@/components/Drawer'; import Drawer from '@/components/Drawer';
import PageMeta from '@/components/PageMeta'; import PageMeta from '@/components/PageMeta';
import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts'; import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -34,11 +34,7 @@ function OrganizationTemplate() {
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false); const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext); const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext); const { isDevTenant } = useContext(TenantsContext);
const isOrganizationsDisabled = const isOrganizationsDisabled = isCloud && !currentSubscriptionQuota.organizationsEnabled;
isCloud &&
!(isDevFeaturesEnabled
? currentSubscriptionQuota.organizationsEnabled
: currentPlan.quota.organizationsEnabled);
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const handleUpgradePlan = useCallback(() => { const handleUpgradePlan = useCallback(() => {

View file

@ -8,7 +8,8 @@ import ReactModal from 'react-modal';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter'; import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { organizationAddOnUnitPrice } from '@/consts/subscriptions'; import { 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';
@ -32,19 +33,14 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
const api = useApi(); const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentPlan, currentSubscription: { planId, isAddOnAvailable },
currentSubscription: { planId },
currentSubscriptionQuota, currentSubscriptionQuota,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { const {
data: { organizationUpsellNoticeAcknowledged }, data: { organizationUpsellNoticeAcknowledged },
update, update,
} = useUserPreferences(); } = useUserPreferences();
const isOrganizationsDisabled = const isOrganizationsDisabled = isCloud && !currentSubscriptionQuota.organizationsEnabled;
isCloud &&
!(isDevFeaturesEnabled
? currentSubscriptionQuota.organizationsEnabled
: currentPlan.quota.organizationsEnabled);
const { const {
reset, reset,
@ -82,12 +78,12 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
<ModalLayout <ModalLayout
title="organizations.create_organization" title="organizations.create_organization"
paywall={conditional( paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro isAddOnAvailable && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)} )}
hasAddOnTag={isDevFeaturesEnabled} hasAddOnTag={isAddOnAvailable}
footer={ footer={
cond( cond(
isDevFeaturesEnabled && isAddOnAvailable &&
planId === ReservedPlanId.Pro && planId === ReservedPlanId.Pro &&
!organizationUpsellNoticeAcknowledged && ( !organizationUpsellNoticeAcknowledged && (
<AddOnNoticeFooter <AddOnNoticeFooter
@ -101,7 +97,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
<Trans <Trans
components={{ components={{
span: <span className={styles.strong} />, span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('upsell.add_on.footer.organization', { {t('upsell.add_on.footer.organization', {

View file

@ -5,7 +5,7 @@ import { useCallback, useContext, useState } from 'react';
import Plus from '@/assets/icons/plus.svg?react'; import Plus from '@/assets/icons/plus.svg?react';
import PageMeta from '@/components/PageMeta'; import PageMeta from '@/components/PageMeta';
import { organizationsFeatureLink } from '@/consts'; import { organizationsFeatureLink } from '@/consts';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -25,17 +25,16 @@ const organizationsPathname = '/organizations';
function Organizations() { function Organizations() {
const { getDocumentationUrl } = useDocumentationUrl(); const { getDocumentationUrl } = useDocumentationUrl();
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext); const {
currentSubscription: { isAddOnAvailable },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext); const { isDevTenant } = useContext(TenantsContext);
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const isOrganizationsDisabled = const isOrganizationsDisabled = isCloud && !currentSubscriptionQuota.organizationsEnabled;
isCloud &&
!(isDevFeaturesEnabled
? currentSubscriptionQuota.organizationsEnabled
: currentPlan.quota.organizationsEnabled);
const upgradePlan = useCallback(() => { const upgradePlan = useCallback(() => {
navigate(subscriptionPage); navigate(subscriptionPage);
@ -61,7 +60,7 @@ function Organizations() {
<div className={pageLayout.headline}> <div className={pageLayout.headline}>
<CardTitle <CardTitle
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)} paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
hasAddOnTag={isDevFeaturesEnabled} hasAddOnTag={isAddOnAvailable}
title="organizations.title" title="organizations.title"
subtitle="organizations.subtitle" subtitle="organizations.subtitle"
learnMoreLink={{ learnMoreLink={{

View file

@ -8,24 +8,21 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import RoleScopesTransfer from '@/components/RoleScopesTransfer'; import RoleScopesTransfer from '@/components/RoleScopesTransfer';
import { isDevFeaturesEnabled } from '@/consts/env';
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';
import ModalLayout from '@/ds-components/ModalLayout'; import ModalLayout from '@/ds-components/ModalLayout';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss'; import modalStyles from '@/scss/modal.module.scss';
import { hasSurpassedQuotaLimit, hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota'; import { hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = { type Props = {
readonly roleId: string; readonly roleId: string;
readonly roleType: RoleType; readonly roleType: RoleType;
/** @deprecated get usage from cloud API after migrating to new pricing model */
readonly totalRoleScopeCount: number;
readonly onClose: (success?: boolean) => void; readonly onClose: (success?: boolean) => void;
}; };
function AssignPermissionsModal({ roleId, roleType, totalRoleScopeCount, onClose }: Props) { function AssignPermissionsModal({ roleId, roleType, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentPlan, currentSku, currentSubscriptionRoleScopeUsage, currentSubscriptionQuota } = const { currentPlan, currentSku, currentSubscriptionRoleScopeUsage, currentSubscriptionQuota } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
@ -52,17 +49,11 @@ function AssignPermissionsModal({ roleId, roleType, totalRoleScopeCount, onClose
} }
}; };
const shouldBlockScopeAssignment = isDevFeaturesEnabled const shouldBlockScopeAssignment = hasSurpassedSubscriptionQuotaLimit({
? hasSurpassedSubscriptionQuotaLimit({ quotaKey: 'scopesPerRoleLimit',
quotaKey: 'scopesPerRoleLimit', usage: (currentSubscriptionRoleScopeUsage[roleId] ?? 0) + scopes.length,
usage: (currentSubscriptionRoleScopeUsage[roleId] ?? 0) + scopes.length, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, });
})
: hasSurpassedQuotaLimit({
quotaKey: 'scopesPerRoleLimit',
plan: currentPlan,
usage: totalRoleScopeCount + scopes.length,
});
return ( return (
<ReactModal <ReactModal
@ -92,10 +83,7 @@ function AssignPermissionsModal({ roleId, roleType, totalRoleScopeCount, onClose
}} }}
> >
{t('upsell.paywall.scopes_per_role', { {t('upsell.paywall.scopes_per_role', {
count: count: currentSubscriptionQuota.scopesPerRoleLimit ?? 0,
(isDevFeaturesEnabled
? currentSubscriptionQuota.scopesPerRoleLimit
: currentPlan.quota.scopesPerRoleLimit) ?? 0,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>

View file

@ -95,7 +95,6 @@ function RolePermissions() {
<AssignPermissionsModal <AssignPermissionsModal
roleId={roleId} roleId={roleId}
roleType={roleType} roleType={roleType}
totalRoleScopeCount={totalCount}
onClose={(success) => { onClose={(success) => {
if (success) { if (success) {
void mutate(); void mutate();

View file

@ -1,69 +1,42 @@
import { type RoleResponse, RoleType } from '@logto/schemas'; import { RoleType } from '@logto/schemas';
import { useContext } from 'react'; import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import useSWR from 'swr';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import { import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit, hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota'; } from '@/utils/quota';
import { buildUrl } from '@/utils/url';
type Props = { type Props = {
readonly roleType: RoleType; readonly roleType: RoleType;
readonly selectedScopesCount: number;
readonly isCreating: boolean; readonly isCreating: boolean;
readonly onClickCreate: () => void; readonly onClickCreate: () => void;
}; };
function Footer({ roleType, selectedScopesCount, isCreating, onClickCreate }: Props) { function Footer({ roleType, isCreating, onClickCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentPlan, currentSku, currentSubscriptionQuota, currentSubscriptionUsage } = const { currentPlan, currentSku, currentSubscriptionQuota, currentSubscriptionUsage } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const { data: [, roleCount] = [] } = useSWR<[RoleResponse[], number]>( const hasRoleReachedLimit = hasReachedSubscriptionQuotaLimit({
isCloud && quotaKey: roleType === RoleType.User ? 'userRolesLimit' : 'machineToMachineRolesLimit',
buildUrl('api/roles', { usage:
page: String(1), roleType === RoleType.User
page_size: String(1), ? currentSubscriptionUsage.userRolesLimit
type: roleType, : currentSubscriptionUsage.machineToMachineRolesLimit,
}) quota: currentSubscriptionQuota,
); });
const hasRoleReachedLimit = isDevFeaturesEnabled const hasScopesPerRoleSurpassedLimit = hasSurpassedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'scopesPerRoleLimit',
quotaKey: roleType === RoleType.User ? 'userRolesLimit' : 'machineToMachineRolesLimit', usage: currentSubscriptionUsage.scopesPerRoleLimit,
usage: quota: currentSubscriptionQuota,
roleType === RoleType.User });
? currentSubscriptionUsage.userRolesLimit
: currentSubscriptionUsage.machineToMachineRolesLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: roleType === RoleType.User ? 'rolesLimit' : 'machineToMachineRolesLimit',
plan: currentPlan,
usage: roleCount ?? 0,
});
const hasScopesPerRoleSurpassedLimit = isDevFeaturesEnabled
? hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'scopesPerRoleLimit',
usage: currentSubscriptionUsage.scopesPerRoleLimit,
quota: currentSubscriptionQuota,
})
: hasSurpassedQuotaLimit({
quotaKey: 'scopesPerRoleLimit',
plan: currentPlan,
usage: selectedScopesCount,
});
if (hasRoleReachedLimit || hasScopesPerRoleSurpassedLimit) { if (hasRoleReachedLimit || hasScopesPerRoleSurpassedLimit) {
return ( return (

View file

@ -77,12 +77,7 @@ function CreateRoleForm({ onClose }: Props) {
}} }}
size="large" size="large"
footer={ footer={
<Footer <Footer roleType={watch('type')} isCreating={isSubmitting} onClickCreate={onSubmit} />
roleType={watch('type')}
selectedScopesCount={watch('scopes', []).length}
isCreating={isSubmitting}
onClickCreate={onSubmit}
/>
} }
onClose={onClose} onClose={onClose}
> >

View file

@ -2,6 +2,7 @@ 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';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { proPlanBasePrice } from '@/consts/subscriptions'; import { proPlanBasePrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
@ -36,7 +37,7 @@ function AddOnUsageChangesNotification({ className }: Props) {
> >
<Trans <Trans
components={{ components={{
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('subscription.usage.pricing.add_on_changes_in_current_cycle_notice', { {t('subscription.usage.pricing.add_on_changes_in_current_cycle_notice', {

View file

@ -4,7 +4,6 @@ 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 { isDevFeaturesEnabled } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -12,27 +11,15 @@ import DynamicT from '@/ds-components/DynamicT';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import NotEligibleSwitchPlanModalContent, { import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
NotEligibleSwitchSkuModalContent, import { parseExceededSkuQuotaLimitError } from '@/utils/subscription';
} from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
import { type SubscriptionPlan } from '@/types/subscriptions';
import {
parseExceededQuotaLimitError,
parseExceededSkuQuotaLimitError,
} from '@/utils/subscription';
type Props = { type Props = {
/** @deprecated No need to pass in this argument in new pricing model */
readonly currentPlan: SubscriptionPlan;
readonly className?: string; readonly className?: string;
readonly periodicUsage?: NewSubscriptionPeriodicUsage; readonly periodicUsage?: NewSubscriptionPeriodicUsage;
}; };
function MauLimitExceededNotification({ function MauLimitExceededNotification({ periodicUsage: rawPeriodicUsage, className }: Props) {
currentPlan,
periodicUsage: rawPeriodicUsage,
className,
}: Props) {
const { currentTenantId } = useContext(TenantsContext); const { currentTenantId } = useContext(TenantsContext);
const { subscribe } = useSubscribe(); const { subscribe } = useSubscribe();
const { show } = useConfirmModal(); const { show } = useConfirmModal();
@ -47,10 +34,6 @@ function MauLimitExceededNotification({
); );
const proSku = useMemo(() => logtoSkus.find(({ id }) => id === ReservedPlanId.Pro), [logtoSkus]); const proSku = useMemo(() => logtoSkus.find(({ id }) => id === ReservedPlanId.Pro), [logtoSkus]);
const {
quota: { mauLimit: oldPricingModelMauLimit },
} = currentPlan;
const periodicUsage = useMemo( const periodicUsage = useMemo(
() => () =>
rawPeriodicUsage ?? rawPeriodicUsage ??
@ -67,10 +50,7 @@ function MauLimitExceededNotification({
return null; return null;
} }
// Should be safe to access `mauLimit` here since we have excluded the case where `isDevFeaturesEnabled` is `true` but `currentSubscriptionQuota` is `null` in the above condition. const { mauLimit } = currentSubscriptionQuota;
const mauLimit = isDevFeaturesEnabled
? currentSubscriptionQuota.mauLimit
: oldPricingModelMauLimit;
if ( if (
mauLimit === null || // Unlimited mauLimit === null || // Unlimited
@ -100,34 +80,14 @@ function MauLimitExceededNotification({
} catch (error: unknown) { } catch (error: unknown) {
setIsLoading(false); setIsLoading(false);
if (isDevFeaturesEnabled) { const [result, exceededSkuQuotaKeys] = await parseExceededSkuQuotaLimitError(error);
const [result, exceededSkuQuotaKeys] = await parseExceededSkuQuotaLimitError(error);
if (result) {
await show({
ModalContent: () => (
<NotEligibleSwitchSkuModalContent
targetSku={proSku}
exceededSkuQuotaKeys={exceededSkuQuotaKeys}
/>
),
title: 'subscription.not_eligible_modal.upgrade_title',
confirmButtonText: 'general.got_it',
confirmButtonType: 'primary',
isCancelButtonVisible: false,
});
return;
}
}
const [result, exceededQuotaKeys] = await parseExceededQuotaLimitError(error);
if (result) { if (result) {
await show({ await show({
ModalContent: () => ( ModalContent: () => (
<NotEligibleSwitchPlanModalContent <NotEligibleSwitchSkuModalContent
targetPlan={proPlan} targetSku={proSku}
exceededQuotaKeys={exceededQuotaKeys} exceededSkuQuotaKeys={exceededSkuQuotaKeys}
/> />
), ),
title: 'subscription.not_eligible_modal.upgrade_title', title: 'subscription.not_eligible_modal.upgrade_title',

View file

@ -1,19 +1,17 @@
import { cond } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import BillInfo from '@/components/BillInfo'; import BillInfo from '@/components/BillInfo';
import ChargeNotification from '@/components/ChargeNotification'; import ChargeNotification from '@/components/ChargeNotification';
import FormCard from '@/components/FormCard'; import FormCard from '@/components/FormCard';
import PlanDescription from '@/components/PlanDescription'; import PlanDescription from '@/components/PlanDescription';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import PlanUsage from '@/components/PlanUsage'; import PlanUsage from '@/components/PlanUsage';
import { isDevFeaturesEnabled } from '@/consts/env';
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';
import { type SubscriptionPlan } from '@/types/subscriptions'; import { hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
import { hasSurpassedQuotaLimit, hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
import AddOnUsageChangesNotification from './AddOnUsageChangesNotification'; import AddOnUsageChangesNotification from './AddOnUsageChangesNotification';
import MauLimitExceedNotification from './MauLimitExceededNotification'; import MauLimitExceedNotification from './MauLimitExceededNotification';
@ -21,22 +19,13 @@ import PaymentOverdueNotification from './PaymentOverdueNotification';
import styles from './index.module.scss'; import styles from './index.module.scss';
type Props = { type Props = {
/** @deprecated */
readonly subscription: Subscription;
/** @deprecated */
readonly subscriptionPlan: SubscriptionPlan;
readonly periodicUsage?: NewSubscriptionPeriodicUsage; readonly periodicUsage?: NewSubscriptionPeriodicUsage;
}; };
function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodicUsage }: Props) { function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
const { currentSku, currentSubscription, currentSubscriptionQuota } = const { currentPlan, currentSku, currentSubscription, currentSubscriptionQuota } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext); const { currentTenant } = useContext(TenantsContext);
const {
id,
name,
quota: { tokenLimit },
} = subscriptionPlan;
const periodicUsage = useMemo( const periodicUsage = useMemo(
() => () =>
@ -63,41 +52,30 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi
return null; return null;
} }
const hasTokenSurpassedLimit = isDevFeaturesEnabled const hasTokenSurpassedLimit = hasSurpassedSubscriptionQuotaLimit({
? hasSurpassedSubscriptionQuotaLimit({ quotaKey: 'tokenLimit',
quotaKey: 'tokenLimit', usage: periodicUsage.tokenLimit,
usage: periodicUsage.tokenLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, });
})
: hasSurpassedQuotaLimit({
quotaKey: 'tokenLimit',
usage: periodicUsage.tokenLimit,
plan: subscriptionPlan,
});
return ( return (
<FormCard title="subscription.current_plan" description="subscription.current_plan_description"> <FormCard title="subscription.current_plan" description="subscription.current_plan_description">
<div className={styles.planInfo}> <div className={styles.planInfo}>
<div className={styles.name}> <div className={styles.name}>
<PlanName skuId={currentSku.id} name={name} /> <PlanName skuId={currentSku.id} name={currentPlan.name} />
</div> </div>
<div className={styles.description}> <div className={styles.description}>
<PlanDescription skuId={currentSku.id} planId={id} /> <PlanDescription skuId={currentSku.id} planId={currentSubscription.planId} />
</div> </div>
</div> </div>
<FormField title="subscription.plan_usage"> <FormField title="subscription.plan_usage">
<PlanUsage <PlanUsage periodicUsage={rawPeriodicUsage} />
currentSubscription={subscription}
currentPlan={subscriptionPlan}
periodicUsage={rawPeriodicUsage}
/>
</FormField> </FormField>
<FormField title="subscription.next_bill"> <FormField title="subscription.next_bill">
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} /> <BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
</FormField> </FormField>
<AddOnUsageChangesNotification className={styles.notification} /> <AddOnUsageChangesNotification className={styles.notification} />
<MauLimitExceedNotification <MauLimitExceedNotification
currentPlan={subscriptionPlan}
periodicUsage={rawPeriodicUsage} periodicUsage={rawPeriodicUsage}
className={styles.notification} className={styles.notification}
/> />
@ -106,13 +84,10 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi
quotaItemPhraseKey="tokens" quotaItemPhraseKey="tokens"
checkedFlagKey="token" checkedFlagKey="token"
className={styles.notification} className={styles.notification}
quotaLimit={ quotaLimit={cond(
cond( typeof currentSubscriptionQuota.tokenLimit === 'number' &&
isDevFeaturesEnabled && currentSubscriptionQuota.tokenLimit
typeof currentSubscriptionQuota.tokenLimit === 'number' && )}
currentSubscriptionQuota.tokenLimit
) ?? cond(typeof tokenLimit === 'number' && tokenLimit)
}
/> />
<PaymentOverdueNotification className={styles.notification} /> <PaymentOverdueNotification className={styles.notification} />
</FormCard> </FormCard>

View file

@ -1,41 +0,0 @@
import { cond } from '@silverhand/essentials';
import {
quotaItemUnlimitedPhrasesMap,
quotaItemPhrasesMap,
quotaItemLimitedPhrasesMap,
} from '@/consts/quota-item-phrases';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
const quotaItemPhraseKeyPrefix = 'subscription.quota_item';
type Props = {
readonly quotaKey: keyof SubscriptionPlanQuota;
readonly quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
};
function QuotaItemPhrase({ quotaKey, quotaValue }: Props) {
const isUnlimited = quotaValue === null;
const isNotCapable = quotaValue === 0 || quotaValue === false;
const isLimited = Boolean(quotaValue);
const phraseKey =
cond(isUnlimited && quotaItemUnlimitedPhrasesMap[quotaKey]) ??
cond(isNotCapable && quotaItemPhrasesMap[quotaKey]) ??
cond(isLimited && quotaItemLimitedPhrasesMap[quotaKey]);
if (!phraseKey) {
// Should not happen
return null;
}
return (
<DynamicT
forKey={`${quotaItemPhraseKeyPrefix}.${phraseKey}`}
interpolation={cond(isLimited && typeof quotaValue === 'number' && { count: quotaValue })}
/>
);
}
export default QuotaItemPhrase;

View file

@ -4,45 +4,10 @@ import classNames from 'classnames';
import DescendArrow from '@/assets/icons/descend-arrow.svg?react'; import DescendArrow from '@/assets/icons/descend-arrow.svg?react';
import Failed from '@/assets/icons/failed.svg?react'; import Failed from '@/assets/icons/failed.svg?react';
import { type LogtoSkuQuota } from '@/types/skus'; import { type LogtoSkuQuota } from '@/types/skus';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import QuotaItemPhrase from './QuotaItemPhrase';
import SkuQuotaItemPhrase from './SkuQuotaItemPhrase'; import SkuQuotaItemPhrase from './SkuQuotaItemPhrase';
import styles from './index.module.scss'; import styles from './index.module.scss';
type Props = {
readonly quotaKey: keyof SubscriptionPlanQuota;
readonly quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
readonly hasStatusIcon?: boolean;
};
/** @deprecated */
function DiffQuotaItem({ quotaKey, quotaValue, hasStatusIcon }: Props) {
const isNotCapable = quotaValue === 0 || quotaValue === false;
const DowngradeStatusIcon = isNotCapable ? Failed : DescendArrow;
return (
<li className={classNames(styles.quotaListItem, hasStatusIcon && styles.withIcon)}>
{/**
* Add a `span` as a wrapper to apply the flex layout to the content.
* If we apply the flex layout to the `li` directly, the `li` circle bullet will disappear.
*/}
<span className={styles.content}>
{cond(
hasStatusIcon && (
<DowngradeStatusIcon
className={classNames(styles.icon, isNotCapable && styles.notCapable)}
/>
)
)}
<span className={cond(isNotCapable && styles.lineThrough)}>
<QuotaItemPhrase quotaKey={quotaKey} quotaValue={quotaValue} />
</span>
</span>
</li>
);
}
type DiffSkuQuotaItemProps = { type DiffSkuQuotaItemProps = {
readonly quotaKey: keyof LogtoSkuQuota; readonly quotaKey: keyof LogtoSkuQuota;
readonly quotaValue: LogtoSkuQuota[keyof LogtoSkuQuota]; readonly quotaValue: LogtoSkuQuota[keyof LogtoSkuQuota];
@ -78,5 +43,3 @@ export function DiffSkuQuotaItem({ quotaKey, quotaValue, hasStatusIcon }: DiffSk
</li> </li>
); );
} }
export default DiffQuotaItem;

View file

@ -1,39 +1,27 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { isDevFeaturesEnabled } from '@/consts/env';
import { type LogtoSkuQuotaEntries } from '@/types/skus'; import { type LogtoSkuQuotaEntries } from '@/types/skus';
import { type SubscriptionPlanQuotaEntries } from '@/types/subscriptions';
import DiffQuotaItem, { DiffSkuQuotaItem } from './DiffQuotaItem'; import { DiffSkuQuotaItem } from './DiffQuotaItem';
import styles from './index.module.scss'; import styles from './index.module.scss';
type Props = { type Props = {
readonly entries: SubscriptionPlanQuotaEntries;
readonly skuQuotaEntries: LogtoSkuQuotaEntries; readonly skuQuotaEntries: LogtoSkuQuotaEntries;
readonly isDowngradeTargetPlan: boolean; readonly isDowngradeTargetPlan: boolean;
readonly className?: string; readonly className?: string;
}; };
function PlanQuotaList({ entries, skuQuotaEntries, isDowngradeTargetPlan, className }: Props) { function PlanQuotaList({ skuQuotaEntries, isDowngradeTargetPlan, className }: Props) {
return ( return (
<ul className={classNames(styles.planQuotaList, className)}> <ul className={classNames(styles.planQuotaList, className)}>
{isDevFeaturesEnabled {skuQuotaEntries.map(([quotaKey, quotaValue]) => (
? skuQuotaEntries.map(([quotaKey, quotaValue]) => ( <DiffSkuQuotaItem
<DiffSkuQuotaItem key={quotaKey}
key={quotaKey} quotaKey={quotaKey}
quotaKey={quotaKey} quotaValue={quotaValue}
quotaValue={quotaValue} hasStatusIcon={isDowngradeTargetPlan}
hasStatusIcon={isDowngradeTargetPlan} />
/> ))}
))
: entries.map(([quotaKey, quotaValue]) => (
<DiffQuotaItem
key={quotaKey}
quotaKey={quotaKey}
quotaValue={quotaValue}
hasStatusIcon={isDowngradeTargetPlan}
/>
))}
</ul> </ul>
); );
} }

View file

@ -2,13 +2,9 @@ import { useMemo } from 'react';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import { planQuotaItemOrder, skuQuotaItemOrder } from '@/consts/plan-quotas'; import { skuQuotaItemOrder } from '@/consts/plan-quotas';
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 {
type SubscriptionPlanQuotaEntries,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import { sortBy } from '@/utils/sort'; import { sortBy } from '@/utils/sort';
import PlanQuotaList from './PlanQuotaList'; import PlanQuotaList from './PlanQuotaList';
@ -16,27 +12,11 @@ import styles from './index.module.scss';
type Props = { type Props = {
readonly planName: string; readonly planName: string;
readonly quotaDiff: Partial<SubscriptionPlanQuota>;
readonly skuQuotaDiff: Partial<LogtoSkuQuota>; readonly skuQuotaDiff: Partial<LogtoSkuQuota>;
readonly isDowngradeTargetPlan?: boolean; readonly isDowngradeTargetPlan?: boolean;
}; };
function PlanQuotaDiffCard({ function PlanQuotaDiffCard({ planName, skuQuotaDiff, isDowngradeTargetPlan = false }: Props) {
planName,
quotaDiff,
skuQuotaDiff,
isDowngradeTargetPlan = false,
}: Props) {
// eslint-disable-next-line no-restricted-syntax
const sortedEntries = useMemo(
() =>
Object.entries(quotaDiff)
.slice()
.sort(([preQuotaKey], [nextQuotaKey]) =>
sortBy(planQuotaItemOrder)(preQuotaKey, nextQuotaKey)
),
[quotaDiff]
) as SubscriptionPlanQuotaEntries;
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const sortedSkuQuotaEntries = useMemo( const sortedSkuQuotaEntries = useMemo(
() => () =>
@ -62,7 +42,6 @@ function PlanQuotaDiffCard({
</Trans> </Trans>
</div> </div>
<PlanQuotaList <PlanQuotaList
entries={sortedEntries}
skuQuotaEntries={sortedSkuQuotaEntries} skuQuotaEntries={sortedSkuQuotaEntries}
isDowngradeTargetPlan={isDowngradeTargetPlan} isDowngradeTargetPlan={isDowngradeTargetPlan}
/> />

View file

@ -78,15 +78,10 @@ function DowngradeConfirmModalContent({ currentPlan, targetPlan, currentSku, tar
</Trans> </Trans>
</div> </div>
<div className={styles.content}> <div className={styles.content}>
<PlanQuotaDiffCard <PlanQuotaDiffCard planName={currentPlanName} skuQuotaDiff={currentSkuQuotaDiff} />
planName={currentPlanName}
quotaDiff={currentQuotaDiff}
skuQuotaDiff={currentSkuQuotaDiff}
/>
<PlanQuotaDiffCard <PlanQuotaDiffCard
isDowngradeTargetPlan isDowngradeTargetPlan
planName={targetPlanName} planName={targetPlanName}
quotaDiff={targetQuotaDiff}
skuQuotaDiff={targetSkuQuotaDiff} skuQuotaDiff={targetSkuQuotaDiff}
/> />
</div> </div>

View file

@ -7,22 +7,15 @@ import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
import { type LogtoSkuResponse } from '@/cloud/types/router'; import { type LogtoSkuResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import { contactEmailLink } from '@/consts'; import { contactEmailLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import Spacer from '@/ds-components/Spacer'; import Spacer from '@/ds-components/Spacer';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import NotEligibleSwitchPlanModalContent, { import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
NotEligibleSwitchSkuModalContent,
} from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
import { type SubscriptionPlan } from '@/types/subscriptions'; import { type SubscriptionPlan } from '@/types/subscriptions';
import { import { isDowngradePlan, parseExceededSkuQuotaLimitError } from '@/utils/subscription';
isDowngradePlan,
parseExceededQuotaLimitError,
parseExceededSkuQuotaLimitError,
} from '@/utils/subscription';
import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent'; import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent';
@ -49,17 +42,17 @@ function SwitchPlanActionBar({
const { show } = useConfirmModal(); const { show } = useConfirmModal();
const [currentLoadingPlanId, setCurrentLoadingPlanId] = useState<string>(); const [currentLoadingPlanId, setCurrentLoadingPlanId] = useState<string>();
// TODO: rename `targetPlanId` to be `targetSkuId` const handleSubscribe = async (targetSkuId: string, isDowngrade: boolean) => {
const handleSubscribe = async (targetPlanId: string, isDowngrade: boolean) => {
if (currentLoadingPlanId) { if (currentLoadingPlanId) {
return; return;
} }
// TODO: clear plan related use cases.
const currentPlan = subscriptionPlans.find(({ id }) => id === currentSubscriptionPlanId); const currentPlan = subscriptionPlans.find(({ id }) => id === currentSubscriptionPlanId);
const targetPlan = subscriptionPlans.find(({ id }) => id === targetPlanId); const targetPlan = subscriptionPlans.find(({ id }) => id === targetSkuId);
const currentSku = logtoSkus.find(({ id }) => id === currentSkuId); const currentSku = logtoSkus.find(({ id }) => id === currentSkuId);
const targetSku = logtoSkus.find(({ id }) => id === targetPlanId); const targetSku = logtoSkus.find(({ id }) => id === targetSkuId);
if (!currentPlan || !targetPlan || !currentSku || !targetSku) { if (!currentPlan || !targetPlan || !currentSku || !targetSku) {
return; return;
@ -86,8 +79,8 @@ function SwitchPlanActionBar({
} }
try { try {
setCurrentLoadingPlanId(targetPlanId); setCurrentLoadingPlanId(targetSkuId);
if (targetPlanId === ReservedPlanId.Free) { if (targetSkuId === ReservedPlanId.Free) {
await cancelSubscription(currentTenantId); await cancelSubscription(currentTenantId);
await onSubscriptionUpdated(); await onSubscriptionUpdated();
toast.success( toast.success(
@ -102,45 +95,22 @@ function SwitchPlanActionBar({
await subscribe({ await subscribe({
tenantId: currentTenantId, tenantId: currentTenantId,
skuId: targetSku.id, skuId: targetSku.id,
planId: targetPlanId, planId: targetSkuId,
isDowngrade, isDowngrade,
callbackPage: subscriptionPage, callbackPage: subscriptionPage,
}); });
} catch (error: unknown) { } catch (error: unknown) {
setCurrentLoadingPlanId(undefined); setCurrentLoadingPlanId(undefined);
if (isDevFeaturesEnabled) { const [result, exceededSkuQuotaKeys] = await parseExceededSkuQuotaLimitError(error);
const [result, exceededSkuQuotaKeys] = await parseExceededSkuQuotaLimitError(error);
if (result) {
await show({
ModalContent: () => (
<NotEligibleSwitchSkuModalContent
targetSku={targetSku}
isDowngrade={isDowngrade}
exceededSkuQuotaKeys={exceededSkuQuotaKeys}
/>
),
title: isDowngrade
? 'subscription.not_eligible_modal.downgrade_title'
: 'subscription.not_eligible_modal.upgrade_title',
confirmButtonText: 'general.got_it',
confirmButtonType: 'primary',
isCancelButtonVisible: false,
});
return;
}
}
const [result, exceededQuotaKeys] = await parseExceededQuotaLimitError(error);
if (result) { if (result) {
await show({ await show({
ModalContent: () => ( ModalContent: () => (
<NotEligibleSwitchPlanModalContent <NotEligibleSwitchSkuModalContent
targetPlan={targetPlan} targetSku={targetSku}
isDowngrade={isDowngrade} isDowngrade={isDowngrade}
exceededQuotaKeys={exceededQuotaKeys} exceededSkuQuotaKeys={exceededSkuQuotaKeys}
/> />
), ),
title: isDowngrade title: isDowngrade
@ -162,56 +132,30 @@ function SwitchPlanActionBar({
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Spacer /> <Spacer />
{isDevFeaturesEnabled {logtoSkus.map(({ id: skuId }) => {
? logtoSkus.map(({ id: skuId }) => { const isCurrentSku = currentSkuId === skuId;
const isCurrentSku = currentSkuId === skuId; const isDowngrade = isDowngradePlan(currentSkuId, skuId);
const isDowngrade = isDowngradePlan(currentSkuId, skuId);
return ( return (
<div key={skuId}> <div key={skuId}>
<Button <Button
title={ title={
isCurrentSku isCurrentSku
? 'subscription.current' ? 'subscription.current'
: isDowngrade : isDowngrade
? 'subscription.downgrade' ? 'subscription.downgrade'
: 'subscription.upgrade' : 'subscription.upgrade'
} }
type={isDowngrade ? 'default' : 'primary'} type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentSku} disabled={isCurrentSku}
isLoading={!isCurrentSku && currentLoadingPlanId === skuId} isLoading={!isCurrentSku && currentLoadingPlanId === skuId}
onClick={() => { onClick={() => {
void handleSubscribe(skuId, isDowngrade); void handleSubscribe(skuId, isDowngrade);
}} }}
/> />
</div> </div>
); );
}) })}
: // TODO remove this branch once new pricing model is ready.
subscriptionPlans.map(({ id: planId }) => {
const isCurrentPlan = currentSubscriptionPlanId === planId;
const isDowngrade = isDowngradePlan(currentSubscriptionPlanId, planId);
return (
<div key={planId}>
<Button
title={
isCurrentPlan
? 'subscription.current'
: isDowngrade
? 'subscription.downgrade'
: 'subscription.upgrade'
}
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentPlan}
isLoading={!isCurrentPlan && currentLoadingPlanId === planId}
onClick={() => {
void handleSubscribe(planId, isDowngrade);
}}
/>
</div>
);
})}
<div> <div>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener"> <a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<Button title="general.contact_us_action" type="primary" /> <Button title="general.contact_us_action" type="primary" />

View file

@ -3,7 +3,7 @@ import useSWR from 'swr';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import PageMeta from '@/components/PageMeta'; import PageMeta from '@/components/PageMeta';
import { isCloud, isDevFeaturesEnabled } 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 { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription'; import { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription';
@ -19,7 +19,6 @@ function Subscription() {
const cloudApi = useCloudApi(); const cloudApi = useCloudApi();
const { const {
subscriptionPlans, subscriptionPlans,
currentPlan,
logtoSkus, logtoSkus,
currentSku, currentSku,
currentSubscription, currentSubscription,
@ -31,9 +30,7 @@ function Subscription() {
const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus); const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus);
const { data: periodicUsage, isLoading } = useSWR( const { data: periodicUsage, isLoading } = useSWR(
isCloud && isCloud && `/api/tenants/${currentTenantId}/subscription/periodic-usage`,
isDevFeaturesEnabled &&
`/api/tenants/${currentTenantId}/subscription/periodic-usage`,
async () => async () =>
cloudApi.get(`/api/tenants/:tenantId/subscription/periodic-usage`, { cloudApi.get(`/api/tenants/:tenantId/subscription/periodic-usage`, {
params: { tenantId: currentTenantId }, params: { tenantId: currentTenantId },
@ -41,7 +38,7 @@ function Subscription() {
); );
useEffect(() => { useEffect(() => {
if (isCloud && isDevFeaturesEnabled) { if (isCloud) {
onCurrentSubscriptionUpdated(); onCurrentSubscriptionUpdated();
} }
}, [onCurrentSubscriptionUpdated]); }, [onCurrentSubscriptionUpdated]);
@ -53,11 +50,7 @@ function Subscription() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} /> <PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
<CurrentPlan <CurrentPlan periodicUsage={periodicUsage} />
subscription={currentSubscription}
subscriptionPlan={currentPlan}
periodicUsage={periodicUsage}
/>
<PlanComparisonTable /> <PlanComparisonTable />
<SwitchPlanActionBar <SwitchPlanActionBar
currentSubscriptionPlanId={currentSubscription.planId} currentSubscriptionPlanId={currentSubscription.planId}

View file

@ -5,7 +5,6 @@ import { Trans, useTranslation } from 'react-i18next';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { contactEmailLink } from '@/consts'; import { contactEmailLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button, { LinkButton } from '@/ds-components/Button'; import Button, { LinkButton } from '@/ds-components/Button';
@ -23,14 +22,11 @@ function Footer({ newInvitationCount = 0, isLoading, onSubmit }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' });
const { currentPlan, currentSku } = useContext(SubscriptionDataContext); const { currentPlan, currentSku } = useContext(SubscriptionDataContext);
const { id: planId, quota } = currentPlan; const { quota } = currentPlan;
const { hasTenantMembersReachedLimit, limit, usage } = useTenantMembersUsage(); const { hasTenantMembersReachedLimit, limit, usage } = useTenantMembersUsage();
if ( if (currentSku.id === ReservedPlanId.Free && hasTenantMembersReachedLimit) {
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Free &&
hasTenantMembersReachedLimit
) {
return ( return (
<QuotaGuardFooter> <QuotaGuardFooter>
<Trans <Trans
@ -45,7 +41,7 @@ function Footer({ newInvitationCount = 0, isLoading, onSubmit }: Props) {
} }
if ( if (
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Development && currentSku.id === ReservedPlanId.Development &&
(hasTenantMembersReachedLimit || usage + newInvitationCount > limit) (hasTenantMembersReachedLimit || usage + newInvitationCount > limit)
) { ) {
// Display a custom "Contact us" footer instead of asking for upgrade // Display a custom "Contact us" footer instead of asking for upgrade

View file

@ -8,7 +8,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 { isDevFeaturesEnabled } from '@/consts/env'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions'; import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
@ -42,7 +42,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const { parseEmailOptions } = useEmailInputUtils(); const { parseEmailOptions } = useEmailInputUtils();
const { show } = useConfirmModal(); const { show } = useConfirmModal();
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionQuota, currentSubscriptionQuota,
currentSubscriptionUsage: { tenantMembersLimit }, currentSubscriptionUsage: { tenantMembersLimit },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
@ -129,14 +129,12 @@ 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( paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro hasAddOnTag={isAddOnAvailable}
)}
hasAddOnTag={isDevFeaturesEnabled}
subtitle="tenant_members.invite_modal.subtitle" subtitle="tenant_members.invite_modal.subtitle"
footer={ footer={
conditional( conditional(
isDevFeaturesEnabled && isAddOnAvailable &&
hasTenantMembersReachedLimit && hasTenantMembersReachedLimit &&
planId === ReservedPlanId.Pro && planId === ReservedPlanId.Pro &&
!tenantMembersUpsellNoticeAcknowledged && ( !tenantMembersUpsellNoticeAcknowledged && (
@ -151,7 +149,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<Trans <Trans
components={{ components={{
span: <span className={styles.strong} />, span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />, a: <TextLink to={addOnPricingExplanationLink} />,
}} }}
> >
{t('upsell.add_on.footer.tenant_members', { {t('upsell.add_on.footer.tenant_members', {

View file

@ -1,94 +1,44 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import useSWR from 'swr';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantInvitationResponse, type TenantMemberResponse } from '@/cloud/types/router';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { type RequestError } from '@/hooks/use-api';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import { import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit, hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota'; } from '@/utils/quota';
const useTenantMembersUsage = () => { const useTenantMembersUsage = () => {
const { currentPlan, currentSubscriptionUsage, currentSubscriptionQuota } = const { currentSubscriptionUsage, currentSubscriptionQuota } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const { currentTenantId } = useContext(TenantsContext);
const {
access: { canInviteMember },
} = useCurrentTenantScopes();
const cloudApi = useAuthedCloudApi();
const { data: members } = useSWR<TenantMemberResponse[], RequestError>(
`api/tenants/${currentTenantId}/members`,
async () =>
cloudApi.get('/api/tenants/:tenantId/members', { params: { tenantId: currentTenantId } })
);
const { data: invitations } = useSWR<TenantInvitationResponse[], RequestError>(
canInviteMember && `api/tenants/${currentTenantId}/invitations`,
async () =>
cloudApi.get('/api/tenants/:tenantId/invitations', { params: { tenantId: currentTenantId } })
);
const pendingInvitations = useMemo(
() => invitations?.filter(({ status }) => status === OrganizationInvitationStatus.Pending),
[invitations]
);
const usage = useMemo(() => { const usage = useMemo(() => {
if (isDevFeaturesEnabled) { return currentSubscriptionUsage.tenantMembersLimit;
return currentSubscriptionUsage.tenantMembersLimit; }, [currentSubscriptionUsage.tenantMembersLimit]);
}
return (members?.length ?? 0) + (pendingInvitations?.length ?? 0);
}, [members?.length, pendingInvitations?.length, currentSubscriptionUsage.tenantMembersLimit]);
const hasTenantMembersReachedLimit = useMemo( const hasTenantMembersReachedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'tenantMembersLimit',
quotaKey: 'tenantMembersLimit', quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, usage: currentSubscriptionUsage.tenantMembersLimit,
usage: currentSubscriptionUsage.tenantMembersLimit, }),
}) [currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
: hasReachedQuotaLimit({
quotaKey: 'tenantMembersLimit',
plan: currentPlan,
usage,
}),
[currentPlan, usage, currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
); );
const hasTenantMembersSurpassedLimit = useMemo( const hasTenantMembersSurpassedLimit = useMemo(
() => () =>
isDevFeaturesEnabled hasSurpassedSubscriptionQuotaLimit({
? hasSurpassedSubscriptionQuotaLimit({ quotaKey: 'tenantMembersLimit',
quotaKey: 'tenantMembersLimit', quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, usage: currentSubscriptionUsage.tenantMembersLimit,
usage: currentSubscriptionUsage.tenantMembersLimit, }),
}) [currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
: hasSurpassedQuotaLimit({
quotaKey: 'tenantMembersLimit',
plan: currentPlan,
usage,
}),
[currentPlan, usage, currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
); );
return { return {
hasTenantMembersReachedLimit, hasTenantMembersReachedLimit,
hasTenantMembersSurpassedLimit, hasTenantMembersSurpassedLimit,
usage, usage,
limit: limit: currentSubscriptionQuota.tenantMembersLimit ?? Number.POSITIVE_INFINITY,
(isDevFeaturesEnabled
? currentSubscriptionQuota.tenantMembersLimit
: currentPlan.quota.tenantMembersLimit) ?? Number.POSITIVE_INFINITY,
}; };
}; };

View file

@ -6,122 +6,22 @@ import { Trans, useTranslation } from 'react-i18next';
import { type LogtoSkuResponse } from '@/cloud/types/router'; import { type LogtoSkuResponse } from '@/cloud/types/router';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import { planQuotaItemOrder, skuQuotaItemOrder } from '@/consts/plan-quotas'; import { skuQuotaItemOrder } from '@/consts/plan-quotas';
import { import {
quotaItemLimitedPhrasesMap,
quotaItemNotEligiblePhrasesMap,
skuQuotaItemLimitedPhrasesMap, skuQuotaItemLimitedPhrasesMap,
skuQuotaItemNotEligiblePhrasesMap, skuQuotaItemNotEligiblePhrasesMap,
} from '@/consts/quota-item-phrases'; } from '@/consts/quota-item-phrases';
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 {
type SubscriptionPlanQuotaEntries,
type SubscriptionPlan,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import { sortBy } from '@/utils/sort'; import { sortBy } from '@/utils/sort';
import styles from './index.module.scss'; import styles from './index.module.scss';
const excludedQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
'auditLogsRetentionDays',
'ticketSupportResponseTime',
]);
const excludedSkuQuotaKeys = new Set<keyof LogtoSkuQuota>([ const excludedSkuQuotaKeys = new Set<keyof LogtoSkuQuota>([
'auditLogsRetentionDays', 'auditLogsRetentionDays',
'ticketSupportResponseTime', 'ticketSupportResponseTime',
]); ]);
type Props = {
readonly targetPlan: SubscriptionPlan;
readonly exceededQuotaKeys: Array<keyof SubscriptionPlanQuota>;
readonly isDowngrade?: boolean;
};
/** @deprecated */
function NotEligibleSwitchPlanModalContent({
targetPlan,
exceededQuotaKeys,
isDowngrade = false,
}: Props) {
const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console.subscription.not_eligible_modal',
});
const { id, name, quota } = targetPlan;
const orderedEntries = useMemo(() => {
// eslint-disable-next-line no-restricted-syntax
const entries = Object.entries(quota) as SubscriptionPlanQuotaEntries;
return entries
.filter(([quotaKey]) => exceededQuotaKeys.includes(quotaKey))
.slice()
.sort(([preQuotaKey], [nextQuotaKey]) =>
sortBy(planQuotaItemOrder)(preQuotaKey, nextQuotaKey)
);
}, [quota, exceededQuotaKeys]);
return (
<div className={styles.container}>
<div className={styles.description}>
<Trans
components={{
name: <PlanName name={name} />,
}}
>
{t(isDowngrade ? 'downgrade_description' : 'upgrade_description')}
</Trans>
{!isDowngrade && id === ReservedPlanId.Pro && t('upgrade_pro_tip')}
</div>
<ul className={styles.list}>
{orderedEntries.map(([quotaKey, quotaValue]) => {
if (
excludedQuotaKeys.has(quotaKey) ||
quotaValue === null || // Unlimited items
quotaValue === true // Eligible items
) {
return null;
}
return (
<li key={quotaKey}>
{quotaValue ? (
<Trans
components={{
item: (
<DynamicT
forKey={`subscription.quota_item.${quotaItemLimitedPhrasesMap[quotaKey]}`}
interpolation={conditional(
typeof quotaValue === 'number' && { count: quotaValue }
)}
/>
),
}}
>
{t('a_maximum_of')}
</Trans>
) : (
<DynamicT
forKey={`subscription.quota_item.${quotaItemNotEligiblePhrasesMap[quotaKey]}`}
/>
)}
</li>
);
})}
</ul>
<Trans
components={{
a: <ContactUsPhraseLink />,
}}
>
{t(isDowngrade ? 'downgrade_help_tip' : 'upgrade_help_tip')}
</Trans>
</div>
);
}
type SkuProps = { type SkuProps = {
readonly targetSku: LogtoSkuResponse; readonly targetSku: LogtoSkuResponse;
readonly exceededSkuQuotaKeys: Array<keyof LogtoSkuQuota>; readonly exceededSkuQuotaKeys: Array<keyof LogtoSkuQuota>;
@ -212,5 +112,3 @@ export function NotEligibleSwitchSkuModalContent({
</div> </div>
); );
} }
export default NotEligibleSwitchPlanModalContent;

View file

@ -7,16 +7,14 @@ import BasicWebhookForm, { type BasicWebhookFormType } from '@/components/BasicW
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink'; import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import ModalLayout from '@/ds-components/ModalLayout'; import ModalLayout from '@/ds-components/ModalLayout';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form'; import { trySubmitSafe } from '@/utils/form';
import { hasReachedQuotaLimit, hasReachedSubscriptionQuotaLimit } from '@/utils/quota'; import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = { type Props = {
readonly totalWebhookCount: number;
readonly onClose: (createdHook?: Hook) => void; readonly onClose: (createdHook?: Hook) => void;
}; };
@ -27,22 +25,16 @@ type CreateHookPayload = Pick<CreateHook, 'name'> & {
}; };
}; };
function CreateForm({ totalWebhookCount, onClose }: Props) { function CreateForm({ onClose }: Props) {
const { currentPlan, currentSku, currentSubscriptionQuota, currentSubscriptionUsage } = const { currentPlan, currentSku, currentSubscriptionQuota, currentSubscriptionUsage } =
useContext(SubscriptionDataContext); useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const shouldBlockCreation = isDevFeaturesEnabled const shouldBlockCreation = hasReachedSubscriptionQuotaLimit({
? hasReachedSubscriptionQuotaLimit({ quotaKey: 'hooksLimit',
quotaKey: 'hooksLimit', usage: currentSubscriptionUsage.hooksLimit,
usage: currentSubscriptionUsage.hooksLimit, quota: currentSubscriptionQuota,
quota: currentSubscriptionQuota, });
})
: hasReachedQuotaLimit({
quotaKey: 'hooksLimit',
usage: totalWebhookCount,
plan: currentPlan,
});
const formMethods = useForm<BasicWebhookFormType>(); const formMethods = useForm<BasicWebhookFormType>();
const { const {
@ -82,10 +74,7 @@ function CreateForm({ totalWebhookCount, onClose }: Props) {
}} }}
> >
{t('upsell.paywall.hooks', { {t('upsell.paywall.hooks', {
count: count: currentSubscriptionUsage.hooksLimit,
(isDevFeaturesEnabled
? currentSubscriptionUsage.hooksLimit
: currentPlan.quota.hooksLimit) ?? 0,
})} })}
</Trans> </Trans>
</QuotaGuardFooter> </QuotaGuardFooter>

View file

@ -7,11 +7,10 @@ import CreateForm from './CreateForm';
type Props = { type Props = {
readonly isOpen: boolean; readonly isOpen: boolean;
readonly totalWebhookCount: number;
readonly onClose: (createdHook?: Hook) => void; readonly onClose: (createdHook?: Hook) => void;
}; };
function CreateFormModal({ isOpen, totalWebhookCount, onClose }: Props) { function CreateFormModal({ isOpen, onClose }: Props) {
return ( return (
<Modal <Modal
shouldCloseOnOverlayClick shouldCloseOnOverlayClick
@ -23,7 +22,7 @@ function CreateFormModal({ isOpen, totalWebhookCount, onClose }: Props) {
onClose(); onClose();
}} }}
> >
<CreateForm totalWebhookCount={totalWebhookCount} onClose={onClose} /> <CreateForm onClose={onClose} />
</Modal> </Modal>
); );
} }

View file

@ -155,7 +155,6 @@ function Webhooks() {
totalCount !== undefined && ( totalCount !== undefined && (
<CreateFormModal <CreateFormModal
isOpen={isCreating} isOpen={isCreating}
totalWebhookCount={totalCount}
onClose={(createdHook?: Hook) => { onClose={(createdHook?: Hook) => {
if (createdHook) { if (createdHook) {
void mutate(); void mutate();

View file

@ -1,40 +1,5 @@
import { type NewSubscriptionQuota } from '@/cloud/types/router'; import { type NewSubscriptionQuota } from '@/cloud/types/router';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
/** @deprecated */
type UsageOptions = {
quotaKey: keyof SubscriptionPlanQuota;
usage: number;
plan: SubscriptionPlan;
};
/** @deprecated */
const isUsageWithInLimit = ({ quotaKey, usage, plan }: UsageOptions, inclusive = true) => {
// No limitations for OSS version
if (!isCloud) {
return true;
}
const quotaValue = plan.quota[quotaKey];
// Unlimited
if (quotaValue === null) {
return true;
}
if (typeof quotaValue === 'boolean') {
return quotaValue;
}
return inclusive ? usage <= quotaValue : usage < quotaValue;
};
/** @deprecated */
export const hasSurpassedQuotaLimit = (options: UsageOptions) => !isUsageWithInLimit(options);
/** @deprecated */
export const hasReachedQuotaLimit = (options: UsageOptions) => !isUsageWithInLimit(options, false);
/* === For new pricing model === */ /* === For new pricing model === */
type SubscriptionUsageOptions = { type SubscriptionUsageOptions = {

View file

@ -7,7 +7,7 @@ import { type LogtoSkuResponse, type SubscriptionPlanResponse } from '@/cloud/ty
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas'; import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions'; import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions';
import { type LogtoSkuQuota } from '@/types/skus'; import { type LogtoSkuQuota } from '@/types/skus';
import { type SubscriptionPlanQuota, type SubscriptionPlan } from '@/types/subscriptions'; import { type SubscriptionPlan } from '@/types/subscriptions';
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => { export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
const { id, quota } = subscriptionPlanResponse; const { id, quota } = subscriptionPlanResponse;
@ -62,51 +62,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
return `${formattedStart} - ${formattedEnd}`; return `${formattedStart} - ${formattedEnd}`;
}; };
/**
* @deprecated Use `parseExceededSkuQuotaLimitError` instead in the future.
*
* Parse the error data from the server if the error is caused by exceeding the quota limit.
* This is used to handle cases where users attempt to switch subscription plans, but the quota limit is exceeded.
*
* @param error - The error object from the server.
*
* @returns If the error is caused by exceeding the quota limit, returns `[true, exceededQuotaKeys]`, otherwise `[false]`.
*
* @remarks
* - This function parses the exceeded quota data from the error message string, since the server which uses `withtyped`
* only supports to return a `message` field in the error response body.
* - The choice to return exceeded quota keys instead of the entire data object is intentional.
* The data returned from the server is quota usage data, but what we want is quota limit data, so we will read quota limits from subscription plans.
*/
export const parseExceededQuotaLimitError = async (
error: unknown
): Promise<[false] | [true, Array<keyof SubscriptionPlanQuota>]> => {
if (!(error instanceof ResponseError)) {
return [false];
}
const { message } = (await tryReadResponseErrorBody(error)) ?? {};
const match = message?.match(/Status exception: Exceeded quota limit\. (.+)$/);
if (!match) {
return [false];
}
const data = match[1];
const exceededQuota = conditional(
// eslint-disable-next-line no-restricted-syntax -- trust the type from the server if error message matches
data && trySafe(() => JSON.parse(data) as Partial<SubscriptionPlanQuota>)
);
if (!exceededQuota) {
return [false];
}
// eslint-disable-next-line no-restricted-syntax
return [true, Object.keys(exceededQuota) as Array<keyof SubscriptionPlanQuota>];
};
// Duplication of `parseExceededQuotaLimitError` with different keys. // Duplication of `parseExceededQuotaLimitError` with different keys.
// `parseExceededQuotaLimitError` will be removed soon. // `parseExceededQuotaLimitError` will be removed soon.
export const parseExceededSkuQuotaLimitError = async ( export const parseExceededSkuQuotaLimitError = async (

View file

@ -1,154 +1,26 @@
import { ConnectorType, DemoConnector } from '@logto/connector-kit'; import { ReservedPlanId } from '@logto/schemas';
import { ReservedPlanId, RoleType } from '@logto/schemas';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { import {
getTenantSubscriptionPlan,
getTenantSubscriptionData, getTenantSubscriptionData,
reportSubscriptionUpdates, reportSubscriptionUpdates,
isReportSubscriptionUpdatesUsageKey, isReportSubscriptionUpdatesUsageKey,
} from '#src/utils/subscription/index.js'; } from '#src/utils/subscription/index.js';
import { type SubscriptionQuota, type FeatureQuota } from '#src/utils/subscription/types.js'; import { type SubscriptionQuota } from '#src/utils/subscription/types.js';
import { type CloudConnectionLibrary } from './cloud-connection.js'; import { type CloudConnectionLibrary } from './cloud-connection.js';
import { type ConnectorLibrary } from './connector.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>; export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
const notNumber = (): never => { const shouldReportSubscriptionUpdates = (
throw new Error('Only support usage query for numeric quota'); planId: string,
}; key: keyof SubscriptionQuota,
isAddOnAvailable?: boolean
const shouldReportSubscriptionUpdates = (planId: string, key: keyof SubscriptionQuota): boolean => ) => planId === ReservedPlanId.Pro && isAddOnAvailable && isReportSubscriptionUpdatesUsageKey(key);
EnvSet.values.isDevFeaturesEnabled &&
planId === ReservedPlanId.Pro &&
isReportSubscriptionUpdatesUsageKey(key);
export const createQuotaLibrary = (
queries: Queries,
cloudConnection: CloudConnectionLibrary,
connectorLibrary: ConnectorLibrary
) => {
const {
applications: { countThirdPartyApplications, countAllApplications, countM2mApplications },
resources: { findTotalNumberOfResources },
hooks: { getTotalNumberOfHooks },
roles: { countRoles },
scopes: { countScopesByResourceId },
rolesScopes: { countRolesScopesByRoleId },
} = queries;
const { getLogtoConnectors } = connectorLibrary;
/** @deprecated */
const tenantUsageQueries: Record<
keyof FeatureQuota,
(queryKey?: string) => Promise<{ count: number }>
> = {
applicationsLimit: countAllApplications,
thirdPartyApplicationsLimit: countThirdPartyApplications,
hooksLimit: getTotalNumberOfHooks,
machineToMachineLimit: countM2mApplications,
resourcesLimit: async () => {
const { count } = await findTotalNumberOfResources();
// Ignore the default management API resource
return { count: count - 1 };
},
rolesLimit: async () => countRoles(undefined, { type: RoleType.User }),
machineToMachineRolesLimit: async () =>
countRoles(undefined, { type: RoleType.MachineToMachine }),
scopesPerResourceLimit: async (queryKey) => {
assertThat(queryKey, new TypeError('queryKey for scopesPerResourceLimit is required'));
return countScopesByResourceId(queryKey);
},
scopesPerRoleLimit: async (queryKey) => {
assertThat(queryKey, new TypeError('queryKey for scopesPerRoleLimit is required'));
return countRolesScopesByRoleId(queryKey);
},
socialConnectorsLimit: async () => {
const connectors = await getLogtoConnectors();
const count = connectors.filter(
({ type, metadata: { id, isStandard } }) =>
type === ConnectorType.Social && !isStandard && id !== DemoConnector.Social
).length;
return { count };
},
tenantMembersLimit: notNumber, // Cloud Admin tenant feature, no limit for now
customDomainEnabled: notNumber,
mfaEnabled: notNumber,
organizationsEnabled: notNumber,
ssoEnabled: notNumber,
omniSignInEnabled: notNumber, // No limit for now
builtInEmailConnectorEnabled: notNumber, // No limit for now
customJwtEnabled: notNumber, // No limit for now
subjectTokenEnabled: notNumber, // No limit for now
bringYourUiEnabled: notNumber,
};
/** @deprecated */
const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise<number> => {
const query = tenantUsageQueries[key];
const { count } = await query(queryKey);
return count;
};
/** @deprecated */
const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => {
const { isCloud, isIntegrationTest } = EnvSet.values;
// Cloud only feature, skip in non-cloud environments
if (!isCloud) {
return;
}
// Disable in integration tests
if (isIntegrationTest) {
return;
}
const { id: planId, quota } = await getTenantSubscriptionPlan(cloudConnection);
// Only apply hard quota limit for free plan, o/w it's soft limit (use `null` to bypass quota check for soft limit cases).
const limit = planId === ReservedPlanId.Free ? quota[key] : null;
if (limit === null) {
return;
}
if (typeof limit === 'boolean') {
assertThat(
limit,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key,
},
})
);
} else if (typeof limit === 'number') {
const tenantUsage = await getTenantUsage(key, queryKey);
assertThat(
tenantUsage < limit,
new RequestError({
code: 'subscription.limit_exceeded',
status: 403,
data: {
key,
limit,
usage: tenantUsage,
},
})
);
} else {
throw new TypeError('Unsupported subscription quota type');
}
};
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
const guardTenantUsageByKey = async (key: keyof SubscriptionQuota) => { const guardTenantUsageByKey = async (key: keyof SubscriptionQuota) => {
const { isCloud, isIntegrationTest } = EnvSet.values; const { isCloud, isIntegrationTest } = EnvSet.values;
@ -164,12 +36,13 @@ export const createQuotaLibrary = (
const { const {
planId, planId,
isAddOnAvailable,
quota: fullQuota, quota: fullQuota,
usage: fullUsage, usage: fullUsage,
} = await getTenantSubscriptionData(cloudConnection); } = await getTenantSubscriptionData(cloudConnection);
// Do not block Pro plan from adding add-on resources. // Do not block Pro plan from adding add-on resources.
if (shouldReportSubscriptionUpdates(planId, key)) { if (shouldReportSubscriptionUpdates(planId, key, isAddOnAvailable)) {
return; return;
} }
@ -284,15 +157,14 @@ export const createQuotaLibrary = (
return; return;
} }
const { planId } = await getTenantSubscriptionData(cloudConnection); const { planId, isAddOnAvailable } = await getTenantSubscriptionData(cloudConnection);
if (shouldReportSubscriptionUpdates(planId, key)) { if (shouldReportSubscriptionUpdates(planId, key, isAddOnAvailable)) {
await reportSubscriptionUpdates(cloudConnection, key); await reportSubscriptionUpdates(cloudConnection, key);
} }
}; };
return { return {
guardKey,
guardTenantUsageByKey, guardTenantUsageByKey,
guardEntityScopesUsage, guardEntityScopesUsage,
reportSubscriptionUpdatesUsage, reportSubscriptionUpdatesUsage,

View file

@ -19,7 +19,7 @@ import type { SsoConnectorLibrary } from '#src/libraries/sso-connector.js';
import { ssoConnectorFactories } from '#src/sso/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { getTenantSubscription, getTenantSubscriptionPlan } from '#src/utils/subscription/index.js'; import { getTenantSubscription } from '#src/utils/subscription/index.js';
import { isKeyOfI18nPhrases } from '#src/utils/translation.js'; import { isKeyOfI18nPhrases } from '#src/utils/translation.js';
import { type CloudConnectionLibrary } from '../cloud-connection.js'; import { type CloudConnectionLibrary } from '../cloud-connection.js';
@ -113,13 +113,8 @@ export const createSignInExperienceLibrary = (
return false; return false;
} }
if (EnvSet.values.isDevFeaturesEnabled) { const subscription = await getTenantSubscription(cloudConnection);
const subscription = await getTenantSubscription(cloudConnection); return subscription.planId === ReservedPlanId.Development;
return subscription.planId === ReservedPlanId.Development;
}
const plan = await getTenantSubscriptionPlan(cloudConnection);
return plan.id === developmentTenantPlanId;
}, ['is-development-tenant']); }, ['is-development-tenant']);
/** /**

View file

@ -2,42 +2,22 @@ import { type Nullable } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa'; import type { MiddlewareType } from 'koa';
import { type QuotaLibrary } from '#src/libraries/quota.js'; import { type QuotaLibrary } from '#src/libraries/quota.js';
import { type SubscriptionQuota, type FeatureQuota } from '#src/utils/subscription/types.js'; import { type SubscriptionQuota } from '#src/utils/subscription/types.js';
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'COPY' | 'HEAD' | 'OPTIONS'; type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'COPY' | 'HEAD' | 'OPTIONS';
/** @deprecated */
type UsageGuardConfig = { type UsageGuardConfig = {
key: keyof FeatureQuota; key: keyof SubscriptionQuota;
quota: QuotaLibrary; quota: QuotaLibrary;
/** Guard usage only for the specified method types. Guard all if not provided. */ /** Guard usage only for the specified method types. Guard all if not provided. */
methods?: Method[]; methods?: Method[];
}; };
type NewUsageGuardConfig = Omit<UsageGuardConfig, 'key'> & { export function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
key: keyof SubscriptionQuota;
};
/** @deprecated */
export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
key, key,
quota, quota,
methods, methods,
}: UsageGuardConfig): MiddlewareType<StateT, ContextT, ResponseBodyT> { }: UsageGuardConfig): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
// eslint-disable-next-line no-restricted-syntax
if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) {
await quota.guardKey(key);
}
return next();
};
}
export function newKoaQuotaGuard<StateT, ContextT, ResponseBodyT>({
key,
quota,
methods,
}: NewUsageGuardConfig): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => { return async (ctx, next) => {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) { if (!methods || methods.includes(ctx.method.toUpperCase() as Method)) {
@ -51,7 +31,7 @@ export function koaReportSubscriptionUpdates<StateT, ContextT, ResponseBodyT>({
key, key,
quota, quota,
methods = ['POST', 'PUT', 'DELETE'], methods = ['POST', 'PUT', 'DELETE'],
}: NewUsageGuardConfig): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> { }: UsageGuardConfig): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> {
return async (ctx, next) => { return async (ctx, next) => {
await next(); await next();

View file

@ -1,5 +1,5 @@
// TODO: @darcyYe refactor this file later to remove disable max line comment // TODO: @darcyYe refactor this file later to remove disable max line comment
/* eslint-disable max-lines */
import type { Role } from '@logto/schemas'; import type { Role } from '@logto/schemas';
import { import {
Applications, Applications,
@ -13,7 +13,6 @@ import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { boolean, object, string, z } from 'zod'; import { boolean, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -148,26 +147,15 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
response: Applications.guard, response: Applications.guard,
status: [200, 400, 422, 500], status: [200, 400, 422, 500],
}), }),
// eslint-disable-next-line complexity
async (ctx, next) => { async (ctx, next) => {
const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body; const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body;
const {
values: { isDevFeaturesEnabled },
} = EnvSet;
await Promise.all([ await Promise.all([
rest.type === ApplicationType.MachineToMachine && rest.type === ApplicationType.MachineToMachine &&
(isDevFeaturesEnabled quota.guardTenantUsageByKey('machineToMachineLimit'),
? quota.guardTenantUsageByKey('machineToMachineLimit') rest.isThirdParty && quota.guardTenantUsageByKey('thirdPartyApplicationsLimit'),
: quota.guardKey('machineToMachineLimit')), quota.guardTenantUsageByKey('applicationsLimit'),
rest.isThirdParty &&
(isDevFeaturesEnabled
? quota.guardTenantUsageByKey('thirdPartyApplicationsLimit')
: quota.guardKey('thirdPartyApplicationsLimit')),
isDevFeaturesEnabled
? quota.guardTenantUsageByKey('applicationsLimit')
: quota.guardKey('applicationsLimit'),
]); ]);
assertThat( assertThat(
@ -371,4 +359,3 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
applicationCustomDataRoutes(router, tenant); applicationCustomDataRoutes(router, tenant);
} }
/* eslint-enable max-lines */

View file

@ -7,7 +7,6 @@ import { conditional } from '@silverhand/essentials';
import cleanDeep from 'clean-deep'; import cleanDeep from 'clean-deep';
import { string, object } from 'zod'; import { string, object } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { type QuotaLibrary } from '#src/libraries/quota.js'; import { type QuotaLibrary } from '#src/libraries/quota.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -27,9 +26,7 @@ const guardConnectorsQuota = async (
quota: QuotaLibrary quota: QuotaLibrary
) => { ) => {
if (factory.type === ConnectorType.Social) { if (factory.type === ConnectorType.Social) {
await (EnvSet.values.isDevFeaturesEnabled await quota.guardTenantUsageByKey('socialConnectorsLimit');
? quota.guardTenantUsageByKey('socialConnectorsLimit')
: quota.guardKey('socialConnectorsLimit'));
} }
}; };

View file

@ -2,10 +2,8 @@ import { Domains, domainResponseGuard, domainSelectFields } from '@logto/schemas
import { pick } from '@silverhand/essentials'; import { pick } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import type { ManagementApiRouter, RouterInitArgs } from './types.js'; import type { ManagementApiRouter, RouterInitArgs } from './types.js';
@ -57,12 +55,6 @@ export default function domainRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/domains', '/domains',
EnvSet.values.isDevFeaturesEnabled
? // We removed custom domain paywall in new pricing model
async (ctx, next) => {
return next();
}
: koaQuotaGuard({ key: 'customDomainEnabled', quota }),
koaGuard({ koaGuard({
body: Domains.createGuard.pick({ domain: true }), body: Domains.createGuard.pick({ domain: true }),
response: domainResponseGuard, response: domainResponseGuard,

View file

@ -14,14 +14,10 @@ import { conditional, deduplicate, yes } from '@silverhand/essentials';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, { import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
koaReportSubscriptionUpdates,
newKoaQuotaGuard,
} from '#src/middleware/koa-quota-guard.js';
import { type AllowedKeyPrefix } from '#src/queries/log.js'; import { type AllowedKeyPrefix } from '#src/queries/log.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -161,9 +157,7 @@ export default function hookRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/hooks', '/hooks',
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'hooksLimit', quota }),
? newKoaQuotaGuard({ key: 'hooksLimit', quota })
: koaQuotaGuard({ key: 'hooksLimit', quota }),
koaGuard({ koaGuard({
body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({ body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({
event: hookEventGuard.optional(), event: hookEventGuard.optional(),

View file

@ -16,7 +16,7 @@ import { EnvSet } from '#src/env-set/index.js';
import RequestError, { formatZodError } from '#src/errors/RequestError/index.js'; import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js'; import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; import { koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
@ -61,9 +61,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard), response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 201, 400, 403], status: [200, 201, 400, 403],
}), }),
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { isCloud, isIntegrationTest } = EnvSet.values; const { isCloud, isIntegrationTest } = EnvSet.values;
if (tenantId === adminTenantId && isCloud && !isIntegrationTest) { if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
@ -114,9 +112,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard), response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 400, 404], status: [200, 400, 404],
}), }),
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { isIntegrationTest } = EnvSet.values; const { isIntegrationTest } = EnvSet.values;
@ -219,9 +215,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: jsonObjectGuard, response: jsonObjectGuard,
status: [200, 400, 403, 422], status: [200, 400, 403, 422],
}), }),
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => { async (ctx, next) => {
const { body } = ctx.guard; const { body } = ctx.guard;

View file

@ -9,14 +9,10 @@ import { generateStandardId } from '@logto/shared';
import { condArray } from '@silverhand/essentials'; import { condArray } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, { import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
koaReportSubscriptionUpdates,
newKoaQuotaGuard,
} from '#src/middleware/koa-quota-guard.js';
import { organizationRoleSearchKeys } from '#src/queries/organization/index.js'; import { organizationRoleSearchKeys } from '#src/queries/organization/index.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js'; import { parseSearchOptions } from '#src/utils/search.js';
@ -50,15 +46,12 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
ManagementApiRouterContext ManagementApiRouterContext
>(OrganizationRoles, roles, { >(OrganizationRoles, roles, {
middlewares: condArray( middlewares: condArray(
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) koaReportSubscriptionUpdates({
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), key: 'organizationsEnabled',
EnvSet.values.isDevFeaturesEnabled && quota,
koaReportSubscriptionUpdates({ methods: ['POST', 'PUT', 'DELETE'],
key: 'organizationsEnabled', })
quota,
methods: ['POST', 'PUT', 'DELETE'],
})
), ),
disabled: { get: true, post: true }, disabled: { get: true, post: true },
errorHandler, errorHandler,

View file

@ -1,11 +1,7 @@
import { OrganizationScopes } from '@logto/schemas'; import { OrganizationScopes } from '@logto/schemas';
import { condArray } from '@silverhand/essentials'; import { condArray } from '@silverhand/essentials';
import { EnvSet } from '#src/env-set/index.js'; import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa-quota-guard.js';
import koaQuotaGuard, {
newKoaQuotaGuard,
koaReportSubscriptionUpdates,
} from '#src/middleware/koa-quota-guard.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { errorHandler } from '../organization/utils.js'; import { errorHandler } from '../organization/utils.js';
@ -24,15 +20,12 @@ export default function organizationScopeRoutes<T extends ManagementApiRouter>(
) { ) {
const router = new SchemaRouter(OrganizationScopes, scopes, { const router = new SchemaRouter(OrganizationScopes, scopes, {
middlewares: condArray( middlewares: condArray(
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) koaReportSubscriptionUpdates({
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), key: 'organizationsEnabled',
EnvSet.values.isDevFeaturesEnabled && quota,
koaReportSubscriptionUpdates({ methods: ['POST', 'PUT', 'DELETE'],
key: 'organizationsEnabled', })
quota,
methods: ['POST', 'PUT', 'DELETE'],
})
), ),
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],

View file

@ -2,13 +2,9 @@ import { type OrganizationWithFeatured, Organizations, featuredUserGuard } from
import { condArray, yes } from '@silverhand/essentials'; import { condArray, yes } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, { import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa-quota-guard.js';
newKoaQuotaGuard,
koaReportSubscriptionUpdates,
} from '#src/middleware/koa-quota-guard.js';
import SchemaRouter from '#src/utils/SchemaRouter.js'; import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js'; import { parseSearchOptions } from '#src/utils/search.js';
@ -35,15 +31,12 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
const router = new SchemaRouter(Organizations, organizations, { const router = new SchemaRouter(Organizations, organizations, {
middlewares: condArray( middlewares: condArray(
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) koaReportSubscriptionUpdates({
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), key: 'organizationsEnabled',
EnvSet.values.isDevFeaturesEnabled && quota,
koaReportSubscriptionUpdates({ methods: ['POST', 'PUT', 'DELETE'],
key: 'organizationsEnabled', })
quota,
methods: ['POST', 'PUT', 'DELETE'],
})
), ),
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],

View file

@ -3,7 +3,6 @@ import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials'; import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -90,9 +89,7 @@ export default function resourceScopeRoutes<T extends ManagementApiRouter>(
body, body,
} = ctx.guard; } = ctx.guard;
await (EnvSet.values.isDevFeaturesEnabled await quota.guardEntityScopesUsage('resources', resourceId);
? quota.guardEntityScopesUsage('resources', resourceId)
: quota.guardKey('scopesPerResourceLimit', resourceId));
assertThat(!/\s/.test(body.name), 'scope.name_with_space'); assertThat(!/\s/.test(body.name), 'scope.name_with_space');

View file

@ -3,14 +3,10 @@ import { generateStandardId } from '@logto/shared';
import { yes } from '@silverhand/essentials'; import { yes } from '@silverhand/essentials';
import { boolean, object, string } from 'zod'; import { boolean, object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, { import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa-quota-guard.js';
newKoaQuotaGuard,
koaReportSubscriptionUpdates,
} from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { attachScopesToResources } from '#src/utils/resource.js'; import { attachScopesToResources } from '#src/utils/resource.js';
@ -80,9 +76,7 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/resources', '/resources',
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'resourcesLimit', quota }),
? newKoaQuotaGuard({ key: 'resourcesLimit', quota })
: koaQuotaGuard({ key: 'resourcesLimit', quota }),
koaGuard({ koaGuard({
// Intentionally omit `isDefault` since it'll affect other rows. // Intentionally omit `isDefault` since it'll affect other rows.
// Use the dedicated API `PATCH /resources/:id/is-default` to update. // Use the dedicated API `PATCH /resources/:id/is-default` to update.

View file

@ -3,7 +3,6 @@ import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials'; import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -94,9 +93,7 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
body: { scopeIds }, body: { scopeIds },
} = ctx.guard; } = ctx.guard;
await (EnvSet.values.isDevFeaturesEnabled await quota.guardEntityScopesUsage('roles', id);
? quota.guardEntityScopesUsage('roles', id)
: quota.guardKey('scopesPerRoleLimit', id));
await validateRoleScopeAssignment(scopeIds, id); await validateRoleScopeAssignment(scopeIds, id);
await insertRolesScopes( await insertRolesScopes(

View file

@ -4,7 +4,6 @@ import { generateStandardId } from '@logto/shared';
import { pickState, trySafe, tryThat } from '@silverhand/essentials'; import { pickState, trySafe, tryThat } from '@silverhand/essentials';
import { number, object, string, z } from 'zod'; import { number, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -151,18 +150,12 @@ export default function roleRoutes<T extends ManagementApiRouter>(
// `rolesLimit` is actually the limit of user roles, keep this name for backward compatibility. // `rolesLimit` is actually the limit of user roles, keep this name for backward compatibility.
// We have optional `type` when creating a new role, if `type` is not provided, use `User` as default. // We have optional `type` when creating a new role, if `type` is not provided, use `User` as default.
// `machineToMachineRolesLimit` is the limit of machine to machine roles, and is independent to `rolesLimit`. // `machineToMachineRolesLimit` is the limit of machine to machine roles, and is independent to `rolesLimit`.
await (EnvSet.values.isDevFeaturesEnabled await quota.guardTenantUsageByKey(
? quota.guardTenantUsageByKey( roleBody.type === RoleType.MachineToMachine
roleBody.type === RoleType.MachineToMachine ? 'machineToMachineRolesLimit'
? 'machineToMachineRolesLimit' : // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`.
: // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`. 'userRolesLimit'
'userRolesLimit' );
)
: quota.guardKey(
roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: 'rolesLimit'
));
assertThat( assertThat(
!(await findRoleByRoleName(roleBody.name)), !(await findRoleByRoleName(roleBody.name)),

View file

@ -5,10 +5,9 @@ import { generateStandardId } from '@logto/shared';
import pRetry, { AbortError } from 'p-retry'; import pRetry, { AbortError } from 'p-retry';
import { object, z } from 'zod'; import { object, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; import { koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import SystemContext from '#src/tenants/SystemContext.js'; import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -30,9 +29,7 @@ export default function customUiAssetsRoutes<T extends ManagementApiRouter>(
) { ) {
router.post( router.post(
'/sign-in-exp/default/custom-ui-assets', '/sign-in-exp/default/custom-ui-assets',
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'bringYourUiEnabled', quota }),
? newKoaQuotaGuard({ key: 'bringYourUiEnabled', quota })
: koaQuotaGuard({ key: 'bringYourUiEnabled', quota }),
koaGuard({ koaGuard({
files: object({ files: object({
file: uploadFileGuard.array().min(1).max(1), file: uploadFileGuard.array().min(1).max(1),

View file

@ -2,7 +2,6 @@ import { DemoConnector } from '@logto/connector-kit';
import { ConnectorType, SignInExperiences } from '@logto/schemas'; import { ConnectorType, SignInExperiences } from '@logto/schemas';
import { literal, object, string, z } from 'zod'; import { literal, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js'; import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js';
import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js'; import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
@ -19,7 +18,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
const { deleteConnectorById } = queries.connectors; const { deleteConnectorById } = queries.connectors;
const { const {
signInExperiences: { validateLanguageInfo }, signInExperiences: { validateLanguageInfo },
quota: { guardKey, guardTenantUsageByKey, reportSubscriptionUpdatesUsage }, quota: { guardTenantUsageByKey, reportSubscriptionUpdatesUsage },
} = libraries; } = libraries;
const { getLogtoConnectors } = connectors; const { getLogtoConnectors } = connectors;
@ -56,7 +55,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
response: SignInExperiences.guard, response: SignInExperiences.guard,
status: [200, 400, 404, 422], status: [200, 400, 404, 422],
}), }),
// eslint-disable-next-line complexity
async (ctx, next) => { async (ctx, next) => {
const { const {
query: { removeUnusedDemoSocialConnector }, query: { removeUnusedDemoSocialConnector },
@ -91,9 +90,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
if (mfa) { if (mfa) {
if (mfa.factors.length > 0) { if (mfa.factors.length > 0) {
await (EnvSet.values.isDevFeaturesEnabled await guardTenantUsageByKey('mfaEnabled');
? guardTenantUsageByKey('mfaEnabled')
: guardKey('mfaEnabled'));
} }
validateMfa(mfa); validateMfa(mfa);
} }

View file

@ -7,14 +7,10 @@ import { generateStandardShortId } from '@logto/shared';
import { assert, conditional } from '@silverhand/essentials'; import { assert, conditional } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, { import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
koaReportSubscriptionUpdates,
newKoaQuotaGuard,
} from '#src/middleware/koa-quota-guard.js';
import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js'; import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js';
import { ssoConnectorFactories } from '#src/sso/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js';
import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js'; import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js';
@ -72,9 +68,7 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
/* Create a new single sign on connector */ /* Create a new single sign on connector */
router.post( router.post(
pathname, pathname,
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'enterpriseSsoLimit', quota }),
? newKoaQuotaGuard({ key: 'enterpriseSsoLimit', quota })
: koaQuotaGuard({ key: 'ssoEnabled', quota }),
koaGuard({ koaGuard({
body: ssoConnectorCreateGuard, body: ssoConnectorCreateGuard,
response: SsoConnectors.guard, response: SsoConnectors.guard,

View file

@ -4,9 +4,8 @@ import { addSeconds } from 'date-fns';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { subjectTokenExpiresIn, subjectTokenPrefix } from '#src/constants/index.js'; import { subjectTokenExpiresIn, subjectTokenPrefix } from '#src/constants/index.js';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; import { koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { type RouterInitArgs, type ManagementApiRouter } from './types.js'; import { type RouterInitArgs, type ManagementApiRouter } from './types.js';
@ -26,9 +25,7 @@ export default function subjectTokenRoutes<T extends ManagementApiRouter>(
router.post( router.post(
'/subject-tokens', '/subject-tokens',
EnvSet.values.isDevFeaturesEnabled koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
? newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota })
: koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
koaGuard({ koaGuard({
body: object({ body: object({
userId: string(), userId: string(),

View file

@ -40,7 +40,7 @@ export default class Libraries {
roleScopes = createRoleScopeLibrary(this.queries); roleScopes = createRoleScopeLibrary(this.queries);
domains = createDomainLibrary(this.queries); domains = createDomainLibrary(this.queries);
protectedApps = createProtectedAppLibrary(this.queries); protectedApps = createProtectedAppLibrary(this.queries);
quota = createQuotaLibrary(this.queries, this.cloudConnection, this.connectors); quota = createQuotaLibrary(this.cloudConnection);
ssoConnectors = createSsoConnectorLibrary(this.queries); ssoConnectors = createSsoConnectorLibrary(this.queries);
signInExperiences = createSignInExperienceLibrary( signInExperiences = createSignInExperienceLibrary(
this.queries, this.queries,

View file

@ -4,7 +4,6 @@ const { jest } = import.meta;
export const createMockQuotaLibrary = (): QuotaLibrary => { export const createMockQuotaLibrary = (): QuotaLibrary => {
return { return {
guardKey: jest.fn(),
guardTenantUsageByKey: jest.fn(), guardTenantUsageByKey: jest.fn(),
guardEntityScopesUsage: jest.fn(), guardEntityScopesUsage: jest.fn(),
reportSubscriptionUpdatesUsage: jest.fn(), reportSubscriptionUpdatesUsage: jest.fn(),

View file

@ -2,12 +2,9 @@ import { trySafe } from '@silverhand/essentials';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import assertThat from '../assert-that.js';
import { import {
type SubscriptionQuota, type SubscriptionQuota,
type SubscriptionUsage, type SubscriptionUsage,
type SubscriptionPlan,
type Subscription, type Subscription,
type ReportSubscriptionUpdatesUsageKey, type ReportSubscriptionUpdatesUsageKey,
allReportSubscriptionUpdatesUsageKeys, allReportSubscriptionUpdatesUsageKeys,
@ -22,37 +19,23 @@ export const getTenantSubscription = async (
return subscription; return subscription;
}; };
export const getTenantSubscriptionPlan = async (
cloudConnection: CloudConnectionLibrary
): Promise<SubscriptionPlan> => {
const client = await cloudConnection.getClient();
const [subscription, plans] = await Promise.all([
getTenantSubscription(cloudConnection),
client.get('/api/subscription-plans'),
]);
const plan = plans.find(({ id }) => id === subscription.planId);
assertThat(plan, 'subscription.get_plan_failed');
return plan;
};
export const getTenantSubscriptionData = async ( export const getTenantSubscriptionData = async (
cloudConnection: CloudConnectionLibrary cloudConnection: CloudConnectionLibrary
): Promise<{ ): Promise<{
planId: string; planId: string;
isAddOnAvailable?: boolean;
quota: SubscriptionQuota; quota: SubscriptionQuota;
usage: SubscriptionUsage; usage: SubscriptionUsage;
resources: Record<string, number>; resources: Record<string, number>;
roles: Record<string, number>; roles: Record<string, number>;
}> => { }> => {
const client = await cloudConnection.getClient(); const client = await cloudConnection.getClient();
const [{ planId }, { quota, usage, resources, roles }] = await Promise.all([ const [{ planId, isAddOnAvailable }, { quota, usage, resources, roles }] = await Promise.all([
client.get('/api/tenants/my/subscription'), client.get('/api/tenants/my/subscription'),
client.get('/api/tenants/my/subscription-usage'), client.get('/api/tenants/my/subscription-usage'),
]); ]);
return { planId, quota, usage, resources, roles }; return { planId, isAddOnAvailable, quota, usage, resources, roles };
}; };
export const reportSubscriptionUpdates = async ( export const reportSubscriptionUpdates = async (

View file

@ -10,17 +10,8 @@ type RouteResponseType<T extends { search?: unknown; body?: unknown; response?:
type RouteRequestBodyType<T extends { search?: unknown; body?: ZodType; response?: unknown }> = type RouteRequestBodyType<T extends { search?: unknown; body?: ZodType; response?: unknown }> =
z.infer<NonNullable<T['body']>>; z.infer<NonNullable<T['body']>>;
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
export type Subscription = RouteResponseType<GetRoutes['/api/tenants/:tenantId/subscription']>; export type Subscription = RouteResponseType<GetRoutes['/api/tenants/:tenantId/subscription']>;
// Since `standardConnectorsLimit` will be removed in the upcoming pricing V2, no need to guard it.
// `tokenLimit` is not guarded in backend.
export type FeatureQuota = Omit<
SubscriptionPlan['quota'],
'tenantLimit' | 'mauLimit' | 'auditLogsRetentionDays' | 'standardConnectorsLimit' | 'tokenLimit'
>;
/** /**
* The type of the response of the `GET /api/tenants/:tenantId/subscription/quota` endpoint. * The type of the response of the `GET /api/tenants/:tenantId/subscription/quota` endpoint.
* It is the same as the response type of `GET /api/tenants/my/subscription/quota` endpoint. * It is the same as the response type of `GET /api/tenants/my/subscription/quota` endpoint.