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:
parent
13bfa0641b
commit
b549a7efd6
87 changed files with 412 additions and 2037 deletions
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -121,7 +121,6 @@ function CreateConnectorForm({ onClose, isOpen: isFormOpen, type }: Props) {
|
|||
existingConnectors && (
|
||||
<Footer
|
||||
isCreatingSocialConnector={isCreatingSocialConnector}
|
||||
existingConnectors={existingConnectors}
|
||||
selectedConnectorGroup={activeGroup}
|
||||
isCreateButtonDisabled={!activeFactoryId}
|
||||
onClickCreateButton={() => {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -84,7 +84,7 @@ function MauExceededModal() {
|
|||
</Trans>
|
||||
</InlineNotification>
|
||||
<FormField title="subscription.plan_usage">
|
||||
<PlanUsage currentSubscription={currentSubscription} currentPlan={currentPlan} />
|
||||
<PlanUsage />
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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/';
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -95,7 +95,6 @@ function RolePermissions() {
|
|||
<AssignPermissionsModal
|
||||
roleId={roleId}
|
||||
roleType={roleType}
|
||||
totalRoleScopeCount={totalCount}
|
||||
onClose={(success) => {
|
||||
if (success) {
|
||||
void mutate();
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -155,7 +155,6 @@ function Webhooks() {
|
|||
totalCount !== undefined && (
|
||||
<CreateFormModal
|
||||
isOpen={isCreating}
|
||||
totalWebhookCount={totalCount}
|
||||
onClose={(createdHook?: Hook) => {
|
||||
if (createdHook) {
|
||||
void mutate();
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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']);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Reference in a new issue