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 PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { machineToMachineAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
@ -24,7 +24,11 @@ type 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 {
hasAppsReachedLimit,
@ -41,7 +45,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
if (
selectedType === ApplicationType.MachineToMachine &&
isDevFeaturesEnabled &&
isAddOnAvailable &&
hasMachineToMachineAppsReachedLimit &&
planId === ReservedPlanId.Pro &&
!m2mUpsellNoticeAcknowledged
@ -58,7 +62,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
<Trans
components={{
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', {
@ -73,7 +77,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
selectedType === ApplicationType.MachineToMachine &&
hasMachineToMachineAppsReachedLimit &&
// 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 (
<QuotaGuardFooter>

View file

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

View file

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

View file

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

View file

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

View file

@ -14,9 +14,8 @@ import DangerousRaw from '@/ds-components/DangerousRaw';
import DynamicT from '@/ds-components/DynamicT';
import TextLink from '@/ds-components/TextLink';
import styles from '../PlanCardItem/index.module.scss';
import FeaturedSkuContent from './FeaturedSkuContent';
import styles from './FeaturedSkuContent/index.module.scss';
type Props = {
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 { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
import { pricingLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import DangerousRaw from '@/ds-components/DangerousRaw';
import ModalLayout from '@/ds-components/ModalLayout';
import TextLink from '@/ds-components/TextLink';
import useLogtoSkus from '@/hooks/use-logto-skus';
import useSubscribe from '@/hooks/use-subscribe';
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
import modalStyles from '@/scss/modal.module.scss';
import { type SubscriptionPlan } from '@/types/subscriptions';
import { pickupFeaturedPlans, pickupFeaturedLogtoSkus } from '@/utils/subscription';
import { pickupFeaturedLogtoSkus } from '@/utils/subscription';
import { type CreateTenantData } from '../types';
import PlanCardItem from './PlanCardItem';
import SkuCardItem from './SkuCardItem';
import styles from './index.module.scss';
@ -31,44 +27,20 @@ type Props = {
};
function SelectTenantPlanModal({ tenantData, onClose }: Props) {
const [processingPlanId, setProcessingPlanId] = useState<string>();
const [processingSkuId, setProcessingSkuId] = useState<string>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: subscriptionPlans } = useSubscriptionPlans();
const { data: logtoSkus } = useLogtoSkus();
const { subscribe } = useSubscribe();
const cloudApi = useCloudApi({ hideErrorToast: true });
const reservedPlans = conditional(subscriptionPlans && pickupFeaturedPlans(subscriptionPlans));
const reservedBasicLogtoSkus = conditional(logtoSkus && pickupFeaturedLogtoSkus(logtoSkus));
if (!reservedPlans || !reservedBasicLogtoSkus || !tenantData) {
if (!reservedBasicLogtoSkus || !tenantData) {
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 { id: skuId } = logtoSku;
try {
@ -113,33 +85,19 @@ function SelectTenantPlanModal({ tenantData, onClose }: Props) {
onClose={onClose}
>
<div className={styles.container}>
{isDevFeaturesEnabled
? reservedBasicLogtoSkus.map((logtoSku) => (
<SkuCardItem
key={logtoSku.id}
sku={logtoSku}
buttonProps={{
isLoading: processingSkuId === logtoSku.id,
disabled: Boolean(processingSkuId),
}}
onSelect={() => {
void handleSelectSku(logtoSku);
}}
/>
))
: reservedPlans.map((plan) => (
<PlanCardItem
key={plan.id}
plan={plan}
buttonProps={{
isLoading: processingPlanId === plan.id,
disabled: Boolean(processingPlanId),
}}
onSelect={() => {
void handleSelectPlan(plan);
}}
/>
))}
{reservedBasicLogtoSkus.map((logtoSku) => (
<SkuCardItem
key={logtoSku.id}
sku={logtoSku}
buttonProps={{
isLoading: processingSkuId === logtoSku.id,
disabled: Boolean(processingSkuId),
}}
onSelect={() => {
void handleSelectSku(logtoSku);
}}
/>
))}
</div>
</ModalLayout>
</Modal>

View file

@ -2,7 +2,7 @@ import { ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -80,11 +80,11 @@ type CombinedAddOnAndFeatureTagProps = {
export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProps) {
const { hasAddOnTag, className, paywall } = props;
const {
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
} = useContext(SubscriptionDataContext);
// 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 (
<div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div>
);

View file

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

View file

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

View file

@ -4,12 +4,10 @@ import classNames from 'classnames';
import dayjs from 'dayjs';
import { useContext, useMemo } from 'react';
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import { isDevFeaturesEnabled } from '@/consts/env';
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlan } from '@/types/subscriptions';
import { formatPeriod } from '@/utils/subscription';
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';
type Props = {
/** @deprecated */
readonly currentSubscription: Subscription;
/** @deprecated */
readonly currentPlan: SubscriptionPlan;
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
};
function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodicUsage }: Props) {
function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
const {
currentSubscriptionQuota,
currentSubscriptionUsage,
@ -32,9 +26,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
} = useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext);
const { currentPeriodStart, currentPeriodEnd } = isDevFeaturesEnabled
? currentSubscriptionFromNewPricingModel
: currentSubscription;
const { currentPeriodStart, currentPeriodEnd } = currentSubscriptionFromNewPricingModel;
const periodicUsage = useMemo(
() =>
@ -52,13 +44,6 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
return null;
}
const [activeUsers, mauLimit] = [
periodicUsage.mauLimit,
isDevFeaturesEnabled ? currentSubscriptionQuota.mauLimit : currentPlan.quota.mauLimit,
];
const mauUsagePercent = conditional(mauLimit && activeUsers / mauLimit);
const usages: PlanUsageCardProps[] = usageKeys
// Show all usages for Pro plan and only show MAU and token usage for Free plan
.filter(
@ -85,7 +70,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
),
}));
return isDevFeaturesEnabled ? (
return (
<div>
<div className={classNames(styles.planCycle, styles.planCycleNewPricingModel)}>
<DynamicT
@ -114,39 +99,6 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
))}
</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 { isDevFeaturesEnabled } from '@/consts/env';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import { type SubscriptionPlan } from '@/types/subscriptions';
type Props = {
readonly tenantData: TenantResponse;
/** @deprecated */
readonly tenantSubscriptionPlan: SubscriptionPlan;
readonly className?: string;
};
function TenantStatusTag({ tenantData, tenantSubscriptionPlan, className }: Props) {
function TenantStatusTag({ tenantData, className }: Props) {
const { usage, quota, openInvoices, isSuspended } = tenantData;
/**
@ -39,7 +35,7 @@ function TenantStatusTag({ tenantData, tenantSubscriptionPlan, className }: Prop
const { activeUsers } = usage;
const mauLimit = isDevFeaturesEnabled ? quota.mauLimit : tenantSubscriptionPlan.quota.mauLimit;
const { mauLimit } = quota;
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;

View file

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

View file

@ -36,3 +36,6 @@ export const organizationJit = Object.freeze({
'/docs/recipes/organizations/just-in-time-provisioning/#enterprise-sso-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,
};
/**
* 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.
*/

View file

@ -1,131 +1,6 @@
import { type TFuncKey } from 'i18next';
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 === */
export const skuQuotaItemPhrasesMap: Record<

View file

@ -1,65 +1,33 @@
import { isManagementApi } from '@logto/schemas';
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 {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useApiResourcesUsage = () => {
const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } =
const { currentSubscriptionQuota, currentSubscriptionUsage } =
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(
() =>
isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: 'resourcesLimit',
plan: currentPlan,
usage: resourceCount,
}),
[currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
hasReachedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
);
const hasSurpassedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
})
: hasSurpassedQuotaLimit({
quotaKey: 'resourcesLimit',
plan: currentPlan,
usage: resourceCount,
}),
[currentPlan, resourceCount, currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
);
return {

View file

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

View file

@ -1,125 +1,53 @@
import { type Application, ApplicationType } from '@logto/schemas';
import { useContext, useMemo } from 'react';
import useSWR from 'swr';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useApplicationsUsage = () => {
const { currentPlan, currentSubscriptionQuota, currentSubscriptionUsage } =
const { currentSubscriptionQuota, currentSubscriptionUsage } =
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(
() =>
isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: 'machineToMachineLimit',
plan: currentPlan,
usage: m2mAppCount,
}),
[
currentPlan,
m2mAppCount,
currentSubscriptionUsage.machineToMachineLimit,
currentSubscriptionQuota,
]
hasReachedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
);
const hasMachineToMachineAppsSurpassedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
})
: hasSurpassedQuotaLimit({
quotaKey: 'machineToMachineLimit',
plan: currentPlan,
usage: m2mAppCount,
}),
[
currentPlan,
m2mAppCount,
currentSubscriptionUsage.machineToMachineLimit,
currentSubscriptionQuota,
]
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
);
const hasThirdPartyAppsReachedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'thirdPartyApplicationsLimit',
usage: currentSubscriptionUsage.thirdPartyApplicationsLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: 'thirdPartyApplicationsLimit',
plan: currentPlan,
usage: thirdPartyAppCount,
}),
[
currentPlan,
thirdPartyAppCount,
currentSubscriptionUsage.thirdPartyApplicationsLimit,
currentSubscriptionQuota,
]
hasReachedSubscriptionQuotaLimit({
quotaKey: 'thirdPartyApplicationsLimit',
usage: currentSubscriptionUsage.thirdPartyApplicationsLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.thirdPartyApplicationsLimit, currentSubscriptionQuota]
);
const hasAppsReachedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'applicationsLimit',
usage: currentSubscriptionUsage.applicationsLimit,
quota: currentSubscriptionQuota,
})
: hasReachedQuotaLimit({
quotaKey: 'applicationsLimit',
plan: currentPlan,
usage: allApplications?.length ?? 0,
}),
[
allApplications?.length,
currentPlan,
currentSubscriptionUsage.applicationsLimit,
currentSubscriptionQuota,
]
hasReachedSubscriptionQuotaLimit({
quotaKey: 'applicationsLimit',
usage: currentSubscriptionUsage.applicationsLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.applicationsLimit, currentSubscriptionQuota]
);
return {

View file

@ -1,5 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import dayjs from 'dayjs';
import { nanoid } from 'nanoid';
import { useContext, useState } from 'react';
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 { type CreateTenantData } from '@/components/CreateTenantModal/types';
import { isDevFeaturesEnabled } from '@/consts/env';
import { checkoutStateQueryKey } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
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.
if (isDevFeaturesEnabled) {
const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', {
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(),
const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', {
params: {
tenantId,
},
});
mutateSubscriptionQuotaAndUsages();
onCurrentSubscriptionUpdated(subscription);
const { id, ...rest } = subscription;
updateTenant(tenantId, {
planId: rest.planId,
subscription: rest,
});
};
const visitManagePaymentPage = async (tenantId: string) => {

View file

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

View file

@ -6,7 +6,7 @@ import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { resourceAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
@ -25,7 +25,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentPlan,
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionUsage: { resourcesLimit },
currentSku,
} = 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
*/
(isDevFeaturesEnabled ? currentSku.id : currentPlan.id) === ReservedPlanId.Free
currentSku.id === ReservedPlanId.Free
) {
return (
<QuotaGuardFooter>
@ -51,7 +51,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
}}
>
{t('upsell.paywall.resources', {
count: (isDevFeaturesEnabled ? resourcesLimit : currentPlan.quota.resourcesLimit) ?? 0,
count: resourcesLimit,
})}
</Trans>
</QuotaGuardFooter>
@ -59,7 +59,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
}
if (
isDevFeaturesEnabled &&
isAddOnAvailable &&
hasReachedLimit &&
planId === ReservedPlanId.Pro &&
!apiResourceUpsellNoticeAcknowledged
@ -76,7 +76,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
<Trans
components={{
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', {

View file

@ -7,7 +7,6 @@ import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
@ -31,7 +30,7 @@ type Props = {
function CreateForm({ onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
} = useContext(SubscriptionDataContext);
const {
@ -67,10 +66,8 @@ function CreateForm({ onClose }: Props) {
<ModalLayout
title="api_resources.create"
subtitle="api_resources.subtitle"
paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)}
hasAddOnTag={isDevFeaturesEnabled}
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
hasAddOnTag={isAddOnAvailable}
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
onClose={onClose}
>

View file

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

View file

@ -11,7 +11,6 @@ import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import AppLoading from '@/components/AppLoading';
import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
import PlanName from '@/components/PlanName';
import { isDevFeaturesEnabled } from '@/consts/env';
import { checkoutStateQueryKey } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -84,43 +83,31 @@ function CheckoutSuccessCallback() {
const isCheckoutSuccessful =
checkoutTenantId &&
stripeCheckoutSession.status === 'complete' &&
(isDevFeaturesEnabled
? !isLoadingLogtoSkus && checkoutSkuId === tenantSubscription?.planId
: !isLoadingPlans && checkoutPlanId === tenantSubscription?.planId);
!isLoadingLogtoSkus &&
checkoutSkuId === tenantSubscription?.planId;
useEffect(() => {
if (isCheckoutSuccessful) {
clearLocalCheckoutSession();
if (isDevFeaturesEnabled) {
const checkoutSku = logtoSkus?.find((sku) => sku.id === checkoutPlanId);
if (checkoutSku) {
toast.success(
<Trans
components={{
name: (
<PlanName
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.
// Also `name` will be deprecated in the future once the new pricing model is ready.
name={checkoutPlanId ?? checkoutSku.name ?? checkoutSku.id}
/>
),
}}
>
{t(isDowngrade ? 'downgrade_success' : 'upgrade_success')}
</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>
);
}
const checkoutSku = logtoSkus?.find((sku) => sku.id === checkoutPlanId);
if (checkoutSku) {
toast.success(
<Trans
components={{
name: (
<PlanName
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.
// Also `name` will be deprecated in the future once the new pricing model is ready.
name={checkoutPlanId ?? checkoutSku.name ?? checkoutSku.id}
/>
),
}}
>
{t(isDowngrade ? 'downgrade_success' : 'upgrade_success')}
</Trans>
);
}
onCurrentSubscriptionUpdated(tenantSubscription);

View file

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

View file

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

View file

@ -17,7 +17,8 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import Skeleton from '@/components/CreateConnectorForm/Skeleton';
import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/utils';
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 { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
@ -49,8 +50,7 @@ const duplicateConnectorNameErrorCode = 'single_sign_on.duplicate_connector_name
function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentPlan,
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const {
@ -61,10 +61,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const isSsoEnabled =
!isCloud ||
(isDevFeaturesEnabled
? currentSubscriptionQuota.enterpriseSsoLimit === null ||
currentSubscriptionQuota.enterpriseSsoLimit > 0
: currentPlan.quota.ssoEnabled);
currentSubscriptionQuota.enterpriseSsoLimit === null ||
currentSubscriptionQuota.enterpriseSsoLimit > 0;
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
'api/sso-connector-providers'
@ -155,11 +153,11 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
<ModalLayout
title="enterprise_sso.create_modal.title"
paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
isAddOnAvailable && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)}
footer={
conditional(
isDevFeaturesEnabled &&
isAddOnAvailable &&
planId === ReservedPlanId.Pro &&
!enterpriseSsoUpsellNoticeAcknowledged && (
<AddOnNoticeFooter
@ -173,7 +171,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
<Trans
components={{
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', {

View file

@ -11,7 +11,7 @@ import EnterpriseSsoConnectorEmpty from '@/assets/images/sso-connector-empty.svg
import ItemPreview from '@/components/ItemPreview';
import ListPage from '@/components/ListPage';
import { defaultPageSize } from '@/consts';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
@ -36,17 +36,16 @@ function EnterpriseSso() {
const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isDevTenant } = useContext(TenantsContext);
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const {
currentSubscription: { isAddOnAvailable },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
page: 1,
});
const isSsoEnabled =
!isCloud ||
(isDevFeaturesEnabled
? currentSubscriptionQuota.enterpriseSsoLimit !== 0
: currentPlan.quota.ssoEnabled);
const isSsoEnabled = !isCloud || currentSubscriptionQuota.enterpriseSsoLimit !== 0;
const url = buildUrl('api/sso-connectors', {
page: String(page),
@ -66,7 +65,7 @@ function EnterpriseSso() {
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
title: 'enterprise_sso.title',
subtitle: 'enterprise_sso.subtitle',
hasAddOnTag: isDevFeaturesEnabled,
hasAddOnTag: isAddOnAvailable,
}}
pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
createButton={conditional(

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { cond } from '@silverhand/essentials';
import { useContext, type ReactNode } from 'react';
import PageMeta from '@/components/PageMeta';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
@ -17,18 +17,17 @@ type Props = {
function PageWrapper({ children }: Props) {
const { isDevTenant } = useContext(TenantsContext);
const {
currentPlan,
currentSubscription: { isAddOnAvailable },
currentSubscriptionQuota: { mfaEnabled },
} = useContext(SubscriptionDataContext);
const isMfaEnabled =
!isCloud || (isDevFeaturesEnabled ? mfaEnabled : currentPlan.quota.mfaEnabled);
const isMfaEnabled = !isCloud || mfaEnabled;
return (
<div className={styles.container}>
<PageMeta titleKey="mfa.title" />
<CardTitle
paywall={cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)}
hasAddOnTag={isDevFeaturesEnabled}
hasAddOnTag={isAddOnAvailable}
title="mfa.title"
subtitle="mfa.description"
className={styles.cardTitle}

View file

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

View file

@ -8,7 +8,8 @@ import ReactModal from 'react-modal';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
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 { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
@ -32,19 +33,14 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentPlan,
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const {
data: { organizationUpsellNoticeAcknowledged },
update,
} = useUserPreferences();
const isOrganizationsDisabled =
isCloud &&
!(isDevFeaturesEnabled
? currentSubscriptionQuota.organizationsEnabled
: currentPlan.quota.organizationsEnabled);
const isOrganizationsDisabled = isCloud && !currentSubscriptionQuota.organizationsEnabled;
const {
reset,
@ -82,12 +78,12 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
<ModalLayout
title="organizations.create_organization"
paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
isAddOnAvailable && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)}
hasAddOnTag={isDevFeaturesEnabled}
hasAddOnTag={isAddOnAvailable}
footer={
cond(
isDevFeaturesEnabled &&
isAddOnAvailable &&
planId === ReservedPlanId.Pro &&
!organizationUpsellNoticeAcknowledged && (
<AddOnNoticeFooter
@ -101,7 +97,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
<Trans
components={{
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', {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { proPlanBasePrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification';
@ -36,7 +37,7 @@ function AddOnUsageChangesNotification({ className }: Props) {
>
<Trans
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', {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,22 +7,15 @@ import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
import { type LogtoSkuResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName';
import { contactEmailLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import Spacer from '@/ds-components/Spacer';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useSubscribe from '@/hooks/use-subscribe';
import NotEligibleSwitchPlanModalContent, {
NotEligibleSwitchSkuModalContent,
} from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
import { type SubscriptionPlan } from '@/types/subscriptions';
import {
isDowngradePlan,
parseExceededQuotaLimitError,
parseExceededSkuQuotaLimitError,
} from '@/utils/subscription';
import { isDowngradePlan, parseExceededSkuQuotaLimitError } from '@/utils/subscription';
import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent';
@ -49,17 +42,17 @@ function SwitchPlanActionBar({
const { show } = useConfirmModal();
const [currentLoadingPlanId, setCurrentLoadingPlanId] = useState<string>();
// TODO: rename `targetPlanId` to be `targetSkuId`
const handleSubscribe = async (targetPlanId: string, isDowngrade: boolean) => {
const handleSubscribe = async (targetSkuId: string, isDowngrade: boolean) => {
if (currentLoadingPlanId) {
return;
}
// TODO: clear plan related use cases.
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 targetSku = logtoSkus.find(({ id }) => id === targetPlanId);
const targetSku = logtoSkus.find(({ id }) => id === targetSkuId);
if (!currentPlan || !targetPlan || !currentSku || !targetSku) {
return;
@ -86,8 +79,8 @@ function SwitchPlanActionBar({
}
try {
setCurrentLoadingPlanId(targetPlanId);
if (targetPlanId === ReservedPlanId.Free) {
setCurrentLoadingPlanId(targetSkuId);
if (targetSkuId === ReservedPlanId.Free) {
await cancelSubscription(currentTenantId);
await onSubscriptionUpdated();
toast.success(
@ -102,45 +95,22 @@ function SwitchPlanActionBar({
await subscribe({
tenantId: currentTenantId,
skuId: targetSku.id,
planId: targetPlanId,
planId: targetSkuId,
isDowngrade,
callbackPage: subscriptionPage,
});
} catch (error: unknown) {
setCurrentLoadingPlanId(undefined);
if (isDevFeaturesEnabled) {
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);
const [result, exceededSkuQuotaKeys] = await parseExceededSkuQuotaLimitError(error);
if (result) {
await show({
ModalContent: () => (
<NotEligibleSwitchPlanModalContent
targetPlan={targetPlan}
<NotEligibleSwitchSkuModalContent
targetSku={targetSku}
isDowngrade={isDowngrade}
exceededQuotaKeys={exceededQuotaKeys}
exceededSkuQuotaKeys={exceededSkuQuotaKeys}
/>
),
title: isDowngrade
@ -162,56 +132,30 @@ function SwitchPlanActionBar({
return (
<div className={styles.container}>
<Spacer />
{isDevFeaturesEnabled
? logtoSkus.map(({ id: skuId }) => {
const isCurrentSku = currentSkuId === skuId;
const isDowngrade = isDowngradePlan(currentSkuId, skuId);
{logtoSkus.map(({ id: skuId }) => {
const isCurrentSku = currentSkuId === skuId;
const isDowngrade = isDowngradePlan(currentSkuId, skuId);
return (
<div key={skuId}>
<Button
title={
isCurrentSku
? 'subscription.current'
: isDowngrade
? 'subscription.downgrade'
: 'subscription.upgrade'
}
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentSku}
isLoading={!isCurrentSku && currentLoadingPlanId === skuId}
onClick={() => {
void handleSubscribe(skuId, isDowngrade);
}}
/>
</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>
);
})}
return (
<div key={skuId}>
<Button
title={
isCurrentSku
? 'subscription.current'
: isDowngrade
? 'subscription.downgrade'
: 'subscription.upgrade'
}
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentSku}
isLoading={!isCurrentSku && currentLoadingPlanId === skuId}
onClick={() => {
void handleSubscribe(skuId, isDowngrade);
}}
/>
</div>
);
})}
<div>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<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 PageMeta from '@/components/PageMeta';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription';
@ -19,7 +19,6 @@ function Subscription() {
const cloudApi = useCloudApi();
const {
subscriptionPlans,
currentPlan,
logtoSkus,
currentSku,
currentSubscription,
@ -31,9 +30,7 @@ function Subscription() {
const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus);
const { data: periodicUsage, isLoading } = useSWR(
isCloud &&
isDevFeaturesEnabled &&
`/api/tenants/${currentTenantId}/subscription/periodic-usage`,
isCloud && `/api/tenants/${currentTenantId}/subscription/periodic-usage`,
async () =>
cloudApi.get(`/api/tenants/:tenantId/subscription/periodic-usage`, {
params: { tenantId: currentTenantId },
@ -41,7 +38,7 @@ function Subscription() {
);
useEffect(() => {
if (isCloud && isDevFeaturesEnabled) {
if (isCloud) {
onCurrentSubscriptionUpdated();
}
}, [onCurrentSubscriptionUpdated]);
@ -53,11 +50,7 @@ function Subscription() {
return (
<div className={styles.container}>
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
<CurrentPlan
subscription={currentSubscription}
subscriptionPlan={currentPlan}
periodicUsage={periodicUsage}
/>
<CurrentPlan periodicUsage={periodicUsage} />
<PlanComparisonTable />
<SwitchPlanActionBar
currentSubscriptionPlanId={currentSubscription.planId}

View file

@ -5,7 +5,6 @@ import { Trans, useTranslation } from 'react-i18next';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { contactEmailLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
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 { currentPlan, currentSku } = useContext(SubscriptionDataContext);
const { id: planId, quota } = currentPlan;
const { quota } = currentPlan;
const { hasTenantMembersReachedLimit, limit, usage } = useTenantMembersUsage();
if (
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Free &&
hasTenantMembersReachedLimit
) {
if (currentSku.id === ReservedPlanId.Free && hasTenantMembersReachedLimit) {
return (
<QuotaGuardFooter>
<Trans
@ -45,7 +41,7 @@ function Footer({ newInvitationCount = 0, isLoading, onSubmit }: Props) {
}
if (
(isDevFeaturesEnabled ? currentSku.id : planId) === ReservedPlanId.Development &&
currentSku.id === ReservedPlanId.Development &&
(hasTenantMembersReachedLimit || usage + newInvitationCount > limit)
) {
// 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 AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links';
import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -42,7 +42,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const { parseEmailOptions } = useEmailInputUtils();
const { show } = useConfirmModal();
const {
currentSubscription: { planId },
currentSubscription: { planId, isAddOnAvailable },
currentSubscriptionQuota,
currentSubscriptionUsage: { tenantMembersLimit },
} = useContext(SubscriptionDataContext);
@ -129,14 +129,12 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<ModalLayout
size="large"
title="tenant_members.invite_modal.title"
paywall={conditional(
isDevFeaturesEnabled && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro
)}
hasAddOnTag={isDevFeaturesEnabled}
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)}
hasAddOnTag={isAddOnAvailable}
subtitle="tenant_members.invite_modal.subtitle"
footer={
conditional(
isDevFeaturesEnabled &&
isAddOnAvailable &&
hasTenantMembersReachedLimit &&
planId === ReservedPlanId.Pro &&
!tenantMembersUpsellNoticeAcknowledged && (
@ -151,7 +149,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<Trans
components={{
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', {

View file

@ -1,94 +1,44 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
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 { TenantsContext } from '@/contexts/TenantsProvider';
import { type RequestError } from '@/hooks/use-api';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import {
hasReachedQuotaLimit,
hasReachedSubscriptionQuotaLimit,
hasSurpassedQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useTenantMembersUsage = () => {
const { currentPlan, currentSubscriptionUsage, currentSubscriptionQuota } =
const { currentSubscriptionUsage, currentSubscriptionQuota } =
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(() => {
if (isDevFeaturesEnabled) {
return currentSubscriptionUsage.tenantMembersLimit;
}
return (members?.length ?? 0) + (pendingInvitations?.length ?? 0);
}, [members?.length, pendingInvitations?.length, currentSubscriptionUsage.tenantMembersLimit]);
return currentSubscriptionUsage.tenantMembersLimit;
}, [currentSubscriptionUsage.tenantMembersLimit]);
const hasTenantMembersReachedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasReachedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
})
: hasReachedQuotaLimit({
quotaKey: 'tenantMembersLimit',
plan: currentPlan,
usage,
}),
[currentPlan, usage, currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
hasReachedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
);
const hasTenantMembersSurpassedLimit = useMemo(
() =>
isDevFeaturesEnabled
? hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
})
: hasSurpassedQuotaLimit({
quotaKey: 'tenantMembersLimit',
plan: currentPlan,
usage,
}),
[currentPlan, usage, currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
);
return {
hasTenantMembersReachedLimit,
hasTenantMembersSurpassedLimit,
usage,
limit:
(isDevFeaturesEnabled
? currentSubscriptionQuota.tenantMembersLimit
: currentPlan.quota.tenantMembersLimit) ?? Number.POSITIVE_INFINITY,
limit: currentSubscriptionQuota.tenantMembersLimit ?? Number.POSITIVE_INFINITY,
};
};

View file

@ -6,122 +6,22 @@ import { Trans, useTranslation } from 'react-i18next';
import { type LogtoSkuResponse } from '@/cloud/types/router';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName';
import { planQuotaItemOrder, skuQuotaItemOrder } from '@/consts/plan-quotas';
import { skuQuotaItemOrder } from '@/consts/plan-quotas';
import {
quotaItemLimitedPhrasesMap,
quotaItemNotEligiblePhrasesMap,
skuQuotaItemLimitedPhrasesMap,
skuQuotaItemNotEligiblePhrasesMap,
} from '@/consts/quota-item-phrases';
import DynamicT from '@/ds-components/DynamicT';
import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus';
import {
type SubscriptionPlanQuotaEntries,
type SubscriptionPlan,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import { sortBy } from '@/utils/sort';
import styles from './index.module.scss';
const excludedQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
'auditLogsRetentionDays',
'ticketSupportResponseTime',
]);
const excludedSkuQuotaKeys = new Set<keyof LogtoSkuQuota>([
'auditLogsRetentionDays',
'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 = {
readonly targetSku: LogtoSkuResponse;
readonly exceededSkuQuotaKeys: Array<keyof LogtoSkuQuota>;
@ -212,5 +112,3 @@ export function NotEligibleSwitchSkuModalContent({
</div>
);
}
export default NotEligibleSwitchPlanModalContent;

View file

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

View file

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

View file

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

View file

@ -1,40 +1,5 @@
import { type NewSubscriptionQuota } from '@/cloud/types/router';
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 === */
type SubscriptionUsageOptions = {

View file

@ -7,7 +7,7 @@ import { type LogtoSkuResponse, type SubscriptionPlanResponse } from '@/cloud/ty
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions';
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) => {
const { id, quota } = subscriptionPlanResponse;
@ -62,51 +62,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
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.
// `parseExceededQuotaLimitError` will be removed soon.
export const parseExceededSkuQuotaLimitError = async (

View file

@ -1,154 +1,26 @@
import { ConnectorType, DemoConnector } from '@logto/connector-kit';
import { ReservedPlanId, RoleType } from '@logto/schemas';
import { ReservedPlanId } from '@logto/schemas';
import { EnvSet } from '#src/env-set/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 {
getTenantSubscriptionPlan,
getTenantSubscriptionData,
reportSubscriptionUpdates,
isReportSubscriptionUpdatesUsageKey,
} 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 ConnectorLibrary } from './connector.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
const notNumber = (): never => {
throw new Error('Only support usage query for numeric quota');
};
const shouldReportSubscriptionUpdates = (planId: string, key: keyof SubscriptionQuota): boolean =>
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');
}
};
const shouldReportSubscriptionUpdates = (
planId: string,
key: keyof SubscriptionQuota,
isAddOnAvailable?: boolean
) => planId === ReservedPlanId.Pro && isAddOnAvailable && isReportSubscriptionUpdatesUsageKey(key);
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
const guardTenantUsageByKey = async (key: keyof SubscriptionQuota) => {
const { isCloud, isIntegrationTest } = EnvSet.values;
@ -164,12 +36,13 @@ export const createQuotaLibrary = (
const {
planId,
isAddOnAvailable,
quota: fullQuota,
usage: fullUsage,
} = await getTenantSubscriptionData(cloudConnection);
// Do not block Pro plan from adding add-on resources.
if (shouldReportSubscriptionUpdates(planId, key)) {
if (shouldReportSubscriptionUpdates(planId, key, isAddOnAvailable)) {
return;
}
@ -284,15 +157,14 @@ export const createQuotaLibrary = (
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);
}
};
return {
guardKey,
guardTenantUsageByKey,
guardEntityScopesUsage,
reportSubscriptionUpdatesUsage,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,14 +14,10 @@ import { conditional, deduplicate, yes } from '@silverhand/essentials';
import { subDays } from 'date-fns';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, {
koaReportSubscriptionUpdates,
newKoaQuotaGuard,
} from '#src/middleware/koa-quota-guard.js';
import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { type AllowedKeyPrefix } from '#src/queries/log.js';
import assertThat from '#src/utils/assert-that.js';
@ -161,9 +157,7 @@ export default function hookRoutes<T extends ManagementApiRouter>(
router.post(
'/hooks',
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'hooksLimit', quota })
: koaQuotaGuard({ key: 'hooksLimit', quota }),
koaQuotaGuard({ key: 'hooksLimit', quota }),
koaGuard({
body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({
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 { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.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 type { ManagementApiRouter, RouterInitArgs } from '../types.js';
@ -61,9 +61,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 201, 400, 403],
}),
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => {
const { isCloud, isIntegrationTest } = EnvSet.values;
if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
@ -114,9 +112,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
status: [200, 400, 404],
}),
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => {
const { isIntegrationTest } = EnvSet.values;
@ -219,9 +215,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
response: jsonObjectGuard,
status: [200, 400, 403, 422],
}),
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota })
: koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
koaQuotaGuard({ key: 'customJwtEnabled', quota: libraries.quota }),
async (ctx, next) => {
const { body } = ctx.guard;

View file

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

View file

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

View file

@ -2,13 +2,9 @@ import { type OrganizationWithFeatured, Organizations, featuredUserGuard } from
import { condArray, yes } from '@silverhand/essentials';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, {
newKoaQuotaGuard,
koaReportSubscriptionUpdates,
} from '#src/middleware/koa-quota-guard.js';
import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa-quota-guard.js';
import SchemaRouter from '#src/utils/SchemaRouter.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, {
middlewares: condArray(
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })
: koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
EnvSet.values.isDevFeaturesEnabled &&
koaReportSubscriptionUpdates({
key: 'organizationsEnabled',
quota,
methods: ['POST', 'PUT', 'DELETE'],
})
koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }),
koaReportSubscriptionUpdates({
key: 'organizationsEnabled',
quota,
methods: ['POST', 'PUT', 'DELETE'],
})
),
errorHandler,
searchFields: ['name'],

View file

@ -3,7 +3,6 @@ import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@ -90,9 +89,7 @@ export default function resourceScopeRoutes<T extends ManagementApiRouter>(
body,
} = ctx.guard;
await (EnvSet.values.isDevFeaturesEnabled
? quota.guardEntityScopesUsage('resources', resourceId)
: quota.guardKey('scopesPerResourceLimit', resourceId));
await quota.guardEntityScopesUsage('resources', resourceId);
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 { boolean, object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, {
newKoaQuotaGuard,
koaReportSubscriptionUpdates,
} from '#src/middleware/koa-quota-guard.js';
import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { attachScopesToResources } from '#src/utils/resource.js';
@ -80,9 +76,7 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
router.post(
'/resources',
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'resourcesLimit', quota })
: koaQuotaGuard({ key: 'resourcesLimit', quota }),
koaQuotaGuard({ key: 'resourcesLimit', quota }),
koaGuard({
// Intentionally omit `isDefault` since it'll affect other rows.
// 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 { object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@ -94,9 +93,7 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
body: { scopeIds },
} = ctx.guard;
await (EnvSet.values.isDevFeaturesEnabled
? quota.guardEntityScopesUsage('roles', id)
: quota.guardKey('scopesPerRoleLimit', id));
await quota.guardEntityScopesUsage('roles', id);
await validateRoleScopeAssignment(scopeIds, id);
await insertRolesScopes(

View file

@ -4,7 +4,6 @@ import { generateStandardId } from '@logto/shared';
import { pickState, trySafe, tryThat } from '@silverhand/essentials';
import { number, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.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.
// 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`.
await (EnvSet.values.isDevFeaturesEnabled
? quota.guardTenantUsageByKey(
roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`.
'userRolesLimit'
)
: quota.guardKey(
roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: 'rolesLimit'
));
await quota.guardTenantUsageByKey(
roleBody.type === RoleType.MachineToMachine
? 'machineToMachineRolesLimit'
: // In new pricing model, we rename `rolesLimit` to `userRolesLimit`, which is easier to be distinguished from `machineToMachineRolesLimit`.
'userRolesLimit'
);
assertThat(
!(await findRoleByRoleName(roleBody.name)),

View file

@ -5,10 +5,9 @@ import { generateStandardId } from '@logto/shared';
import pRetry, { AbortError } from 'p-retry';
import { object, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.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 assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -30,9 +29,7 @@ export default function customUiAssetsRoutes<T extends ManagementApiRouter>(
) {
router.post(
'/sign-in-exp/default/custom-ui-assets',
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'bringYourUiEnabled', quota })
: koaQuotaGuard({ key: 'bringYourUiEnabled', quota }),
koaQuotaGuard({ key: 'bringYourUiEnabled', quota }),
koaGuard({
files: object({
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 { 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 { validateMfa } from '#src/libraries/sign-in-experience/mfa.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 {
signInExperiences: { validateLanguageInfo },
quota: { guardKey, guardTenantUsageByKey, reportSubscriptionUpdatesUsage },
quota: { guardTenantUsageByKey, reportSubscriptionUpdatesUsage },
} = libraries;
const { getLogtoConnectors } = connectors;
@ -56,7 +55,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
response: SignInExperiences.guard,
status: [200, 400, 404, 422],
}),
// eslint-disable-next-line complexity
async (ctx, next) => {
const {
query: { removeUnusedDemoSocialConnector },
@ -91,9 +90,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
if (mfa) {
if (mfa.factors.length > 0) {
await (EnvSet.values.isDevFeaturesEnabled
? guardTenantUsageByKey('mfaEnabled')
: guardKey('mfaEnabled'));
await guardTenantUsageByKey('mfaEnabled');
}
validateMfa(mfa);
}

View file

@ -7,14 +7,10 @@ import { generateStandardShortId } from '@logto/shared';
import { assert, conditional } from '@silverhand/essentials';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard, {
koaReportSubscriptionUpdates,
newKoaQuotaGuard,
} from '#src/middleware/koa-quota-guard.js';
import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa-quota-guard.js';
import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js';
import { ssoConnectorFactories } from '#src/sso/index.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 */
router.post(
pathname,
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'enterpriseSsoLimit', quota })
: koaQuotaGuard({ key: 'ssoEnabled', quota }),
koaQuotaGuard({ key: 'enterpriseSsoLimit', quota }),
koaGuard({
body: ssoConnectorCreateGuard,
response: SsoConnectors.guard,

View file

@ -4,9 +4,8 @@ import { addSeconds } from 'date-fns';
import { object, string } from 'zod';
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 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';
@ -26,9 +25,7 @@ export default function subjectTokenRoutes<T extends ManagementApiRouter>(
router.post(
'/subject-tokens',
EnvSet.values.isDevFeaturesEnabled
? newKoaQuotaGuard({ key: 'subjectTokenEnabled', quota })
: koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
koaQuotaGuard({ key: 'subjectTokenEnabled', quota }),
koaGuard({
body: object({
userId: string(),

View file

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

View file

@ -4,7 +4,6 @@ const { jest } = import.meta;
export const createMockQuotaLibrary = (): QuotaLibrary => {
return {
guardKey: jest.fn(),
guardTenantUsageByKey: jest.fn(),
guardEntityScopesUsage: 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 assertThat from '../assert-that.js';
import {
type SubscriptionQuota,
type SubscriptionUsage,
type SubscriptionPlan,
type Subscription,
type ReportSubscriptionUpdatesUsageKey,
allReportSubscriptionUpdatesUsageKeys,
@ -22,37 +19,23 @@ export const getTenantSubscription = async (
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 (
cloudConnection: CloudConnectionLibrary
): Promise<{
planId: string;
isAddOnAvailable?: boolean;
quota: SubscriptionQuota;
usage: SubscriptionUsage;
resources: Record<string, number>;
roles: Record<string, number>;
}> => {
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-usage'),
]);
return { planId, quota, usage, resources, roles };
return { planId, isAddOnAvailable, quota, usage, resources, roles };
};
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 }> =
z.infer<NonNullable<T['body']>>;
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
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.
* It is the same as the response type of `GET /api/tenants/my/subscription/quota` endpoint.