mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(console): separate subscription based usage (#6448)
* refactor(console): separate subscription based usage * refactor: add periodic usage fallback to avoid breaking changes * fix: fix mock tenant data
This commit is contained in:
parent
db42279ed4
commit
87ff8cb8af
10 changed files with 148 additions and 36 deletions
|
@ -27,7 +27,7 @@
|
|||
"devDependencies": {
|
||||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@jest/types": "^29.5.0",
|
||||
"@logto/cloud": "0.2.5-a6cff75",
|
||||
"@logto/cloud": "0.2.5-9a1b047",
|
||||
"@logto/connector-kit": "workspace:^4.0.0",
|
||||
"@logto/core-kit": "workspace:^2.5.0",
|
||||
"@logto/elements": "workspace:^0.0.0",
|
||||
|
|
|
@ -22,10 +22,14 @@ export type NewSubscriptionUsageResponse = GuardedResponse<
|
|||
/** The response of `GET /api/tenants/my/subscription/quota` has the same response type. */
|
||||
export type NewSubscriptionQuota = NewSubscriptionUsageResponse['quota'];
|
||||
/** The response of `GET /api/tenants/my/subscription/usage` has the same response type. */
|
||||
export type NewSubscriptionUsage = NewSubscriptionUsageResponse['usage'];
|
||||
export type NewSubscriptionCountBasedUsage = NewSubscriptionUsageResponse['usage'];
|
||||
export type NewSubscriptionResourceScopeUsage = NewSubscriptionUsageResponse['resources'];
|
||||
export type NewSubscriptionRoleScopeUsage = NewSubscriptionUsageResponse['roles'];
|
||||
|
||||
export type NewSubscriptionPeriodicUsage = GuardedResponse<
|
||||
GetRoutes['/api/tenants/:tenantId/subscription/periodic-usage']
|
||||
>;
|
||||
|
||||
/* ===== Use `New` in the naming to avoid confusion with legacy types ===== */
|
||||
|
||||
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { cond } from '@silverhand/essentials';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
@ -6,6 +7,7 @@ import PlanUsage from '@/components/PlanUsage';
|
|||
import { contactEmailLink } from '@/consts';
|
||||
import { subscriptionPage } from '@/consts/pages';
|
||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
|
@ -18,13 +20,8 @@ import PlanName from '../PlanName';
|
|||
import styles from './index.module.scss';
|
||||
|
||||
function MauExceededModal() {
|
||||
const {
|
||||
currentPlan,
|
||||
currentSubscription,
|
||||
currentSku,
|
||||
currentSubscriptionQuota,
|
||||
currentSubscriptionUsage,
|
||||
} = useContext(SubscriptionDataContext);
|
||||
const { currentPlan, currentSubscription, currentSku } = useContext(SubscriptionDataContext);
|
||||
const { currentTenant } = useContext(TenantsContext);
|
||||
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { navigate } = useTenantPathname();
|
||||
|
@ -41,8 +38,10 @@ function MauExceededModal() {
|
|||
const { name: planName } = currentPlan;
|
||||
|
||||
const isMauExceeded =
|
||||
currentSubscriptionQuota.mauLimit !== null &&
|
||||
currentSubscriptionUsage.mauLimit >= currentSubscriptionQuota.mauLimit;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain, prettier/prettier
|
||||
cond(currentTenant && currentTenant.quota.mauLimit !== null &&
|
||||
currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit
|
||||
);
|
||||
|
||||
if (!isMauExceeded) {
|
||||
return null;
|
||||
|
|
|
@ -2,11 +2,12 @@ import { ReservedPlanId } from '@logto/schemas';
|
|||
import { cond, conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import { type Subscription } from '@/cloud/types/router';
|
||||
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
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';
|
||||
|
@ -20,28 +21,49 @@ type Props = {
|
|||
readonly currentSubscription: Subscription;
|
||||
/** @deprecated */
|
||||
readonly currentPlan: SubscriptionPlan;
|
||||
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||
};
|
||||
|
||||
function PlanUsage({ currentSubscription, currentPlan }: Props) {
|
||||
function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodicUsage }: Props) {
|
||||
const {
|
||||
currentSubscriptionQuota,
|
||||
currentSubscriptionUsage,
|
||||
currentSubscription: currentSubscriptionFromNewPricingModel,
|
||||
} = useContext(SubscriptionDataContext);
|
||||
const { currentTenant } = useContext(TenantsContext);
|
||||
|
||||
const { currentPeriodStart, currentPeriodEnd } = isDevFeaturesEnabled
|
||||
? currentSubscriptionFromNewPricingModel
|
||||
: currentSubscription;
|
||||
|
||||
const periodicUsage = useMemo(
|
||||
() =>
|
||||
rawPeriodicUsage ??
|
||||
conditional(
|
||||
currentTenant && {
|
||||
mauLimit: currentTenant.usage.activeUsers,
|
||||
tokenLimit: currentTenant.usage.tokenUsage,
|
||||
}
|
||||
),
|
||||
[currentTenant, rawPeriodicUsage]
|
||||
);
|
||||
|
||||
if (!periodicUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [activeUsers, mauLimit] = [
|
||||
currentSubscriptionUsage.mauLimit,
|
||||
periodicUsage.mauLimit,
|
||||
isDevFeaturesEnabled ? currentSubscriptionQuota.mauLimit : currentPlan.quota.mauLimit,
|
||||
];
|
||||
|
||||
const usagePercent = conditional(mauLimit && activeUsers / mauLimit);
|
||||
|
||||
const usages: ProPlanUsageCardProps[] = usageKeys.map((key) => ({
|
||||
usage: currentSubscriptionUsage[key],
|
||||
usage:
|
||||
key === 'mauLimit' || key === 'tokenLimit'
|
||||
? periodicUsage[key]
|
||||
: currentSubscriptionUsage[key],
|
||||
usageKey: `subscription.usage.${usageKeyMap[key]}`,
|
||||
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
||||
tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`,
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
type NewSubscriptionQuota,
|
||||
type LogtoSkuResponse,
|
||||
type TenantResponse,
|
||||
type NewSubscriptionUsage,
|
||||
type NewSubscriptionCountBasedUsage,
|
||||
} from '@/cloud/types/router';
|
||||
import { RegionName } from '@/components/Region';
|
||||
import { LogtoSkuType } from '@/types/skus';
|
||||
|
@ -34,9 +34,11 @@ export const defaultTenantResponse: TenantResponse = {
|
|||
},
|
||||
usage: {
|
||||
activeUsers: 0,
|
||||
tokenUsage: 0,
|
||||
},
|
||||
quota: {
|
||||
mauLimit: null,
|
||||
tokenLimit: null,
|
||||
},
|
||||
openInvoices: [],
|
||||
isSuspended: false,
|
||||
|
@ -143,9 +145,7 @@ export const defaultSubscriptionQuota: NewSubscriptionQuota = {
|
|||
bringYourUiEnabled: false,
|
||||
};
|
||||
|
||||
export const defaultSubscriptionUsage: NewSubscriptionUsage = {
|
||||
mauLimit: 0,
|
||||
tokenLimit: 0,
|
||||
export const defaultSubscriptionUsage: NewSubscriptionCountBasedUsage = {
|
||||
applicationsLimit: 0,
|
||||
machineToMachineLimit: 0,
|
||||
resourcesLimit: 0,
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
type LogtoSkuResponse,
|
||||
type Subscription,
|
||||
type NewSubscriptionQuota,
|
||||
type NewSubscriptionUsage,
|
||||
type NewSubscriptionCountBasedUsage,
|
||||
type NewSubscriptionResourceScopeUsage,
|
||||
type NewSubscriptionRoleScopeUsage,
|
||||
} from '@/cloud/types/router';
|
||||
|
@ -21,7 +21,7 @@ type NewSubscriptionSupplementContext = {
|
|||
logtoSkus: LogtoSkuResponse[];
|
||||
currentSku: LogtoSkuResponse;
|
||||
currentSubscriptionQuota: NewSubscriptionQuota;
|
||||
currentSubscriptionUsage: NewSubscriptionUsage;
|
||||
currentSubscriptionUsage: NewSubscriptionCountBasedUsage;
|
||||
currentSubscriptionResourceScopeUsage: NewSubscriptionResourceScopeUsage;
|
||||
currentSubscriptionRoleScopeUsage: NewSubscriptionRoleScopeUsage;
|
||||
mutateSubscriptionQuotaAndUsages: () => void;
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { ReservedPlanId } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
|
||||
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
import { subscriptionPage } from '@/consts/pages';
|
||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||
|
@ -23,14 +25,20 @@ 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, className }: Props) {
|
||||
function MauLimitExceededNotification({
|
||||
currentPlan,
|
||||
periodicUsage: rawPeriodicUsage,
|
||||
className,
|
||||
}: Props) {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { subscribe } = useSubscribe();
|
||||
const { show } = useConfirmModal();
|
||||
const { subscriptionPlans, logtoSkus, currentSubscriptionQuota, currentSubscriptionUsage } =
|
||||
const { subscriptionPlans, logtoSkus, currentSubscriptionQuota } =
|
||||
useContext(SubscriptionDataContext);
|
||||
const { currentTenant } = useContext(TenantsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const proPlan = useMemo(
|
||||
|
@ -43,6 +51,22 @@ function MauLimitExceededNotification({ currentPlan, className }: Props) {
|
|||
quota: { mauLimit: oldPricingModelMauLimit },
|
||||
} = currentPlan;
|
||||
|
||||
const periodicUsage = useMemo(
|
||||
() =>
|
||||
rawPeriodicUsage ??
|
||||
conditional(
|
||||
currentTenant && {
|
||||
mauLimit: currentTenant.usage.activeUsers,
|
||||
tokenLimit: currentTenant.usage.tokenUsage,
|
||||
}
|
||||
),
|
||||
[currentTenant, rawPeriodicUsage]
|
||||
);
|
||||
|
||||
if (!periodicUsage) {
|
||||
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
|
||||
|
@ -50,7 +74,7 @@ function MauLimitExceededNotification({ currentPlan, className }: Props) {
|
|||
|
||||
if (
|
||||
mauLimit === null || // Unlimited
|
||||
currentSubscriptionUsage.mauLimit < mauLimit ||
|
||||
periodicUsage.mauLimit < mauLimit ||
|
||||
!proPlan ||
|
||||
!proSku
|
||||
) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { cond } from '@silverhand/essentials';
|
||||
import { cond, conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import { type Subscription } from '@/cloud/types/router';
|
||||
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||
import BillInfo from '@/components/BillInfo';
|
||||
import ChargeNotification from '@/components/ChargeNotification';
|
||||
import FormCard from '@/components/FormCard';
|
||||
|
@ -10,6 +10,7 @@ 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';
|
||||
|
@ -23,10 +24,12 @@ type Props = {
|
|||
readonly subscription: Subscription;
|
||||
/** @deprecated */
|
||||
readonly subscriptionPlan: SubscriptionPlan;
|
||||
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||
};
|
||||
|
||||
function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
||||
const { currentSku, currentSubscription, currentSubscriptionUsage, currentSubscriptionQuota } =
|
||||
function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodicUsage }: Props) {
|
||||
const { currentTenant } = useContext(TenantsContext);
|
||||
const { currentSku, currentSubscription, currentSubscriptionQuota } =
|
||||
useContext(SubscriptionDataContext);
|
||||
const {
|
||||
id,
|
||||
|
@ -34,6 +37,18 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
|||
quota: { tokenLimit },
|
||||
} = subscriptionPlan;
|
||||
|
||||
const periodicUsage = useMemo(
|
||||
() =>
|
||||
rawPeriodicUsage ??
|
||||
conditional(
|
||||
currentTenant && {
|
||||
mauLimit: currentTenant.usage.activeUsers,
|
||||
tokenLimit: currentTenant.usage.tokenUsage,
|
||||
}
|
||||
),
|
||||
[currentTenant, rawPeriodicUsage]
|
||||
);
|
||||
|
||||
/**
|
||||
* After the new pricing model goes live, `upcomingInvoice` will always exist. However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0.
|
||||
*/
|
||||
|
@ -42,15 +57,19 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
|||
[currentSku.unitPrice, currentSubscription.upcomingInvoice?.subtotal]
|
||||
);
|
||||
|
||||
if (!periodicUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasTokenSurpassedLimit = isDevFeaturesEnabled
|
||||
? hasSurpassedSubscriptionQuotaLimit({
|
||||
quotaKey: 'tokenLimit',
|
||||
usage: currentSubscriptionUsage.tokenLimit,
|
||||
usage: periodicUsage.tokenLimit,
|
||||
quota: currentSubscriptionQuota,
|
||||
})
|
||||
: hasSurpassedQuotaLimit({
|
||||
quotaKey: 'tokenLimit',
|
||||
usage: currentSubscriptionUsage.tokenLimit,
|
||||
usage: periodicUsage.tokenLimit,
|
||||
plan: subscriptionPlan,
|
||||
});
|
||||
|
||||
|
@ -65,12 +84,20 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
<FormField title="subscription.plan_usage">
|
||||
<PlanUsage currentSubscription={subscription} currentPlan={subscriptionPlan} />
|
||||
<PlanUsage
|
||||
currentSubscription={subscription}
|
||||
currentPlan={subscriptionPlan}
|
||||
periodicUsage={rawPeriodicUsage}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="subscription.next_bill">
|
||||
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
|
||||
</FormField>
|
||||
<MauLimitExceedNotification currentPlan={subscriptionPlan} className={styles.notification} />
|
||||
<MauLimitExceedNotification
|
||||
currentPlan={subscriptionPlan}
|
||||
periodicUsage={rawPeriodicUsage}
|
||||
className={styles.notification}
|
||||
/>
|
||||
<ChargeNotification
|
||||
hasSurpassedLimit={hasTokenSurpassedLimit}
|
||||
quotaItemPhraseKey="tokens"
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
|
||||
import { Skeleton } from '@/containers/ConsoleContent/Sidebar';
|
||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription';
|
||||
|
||||
import CurrentPlan from './CurrentPlan';
|
||||
|
@ -10,6 +15,7 @@ import SwitchPlanActionBar from './SwitchPlanActionBar';
|
|||
import styles from './index.module.scss';
|
||||
|
||||
function Subscription() {
|
||||
const cloudApi = useCloudApi();
|
||||
const {
|
||||
subscriptionPlans,
|
||||
currentPlan,
|
||||
|
@ -18,14 +24,33 @@ function Subscription() {
|
|||
currentSubscription,
|
||||
onCurrentSubscriptionUpdated,
|
||||
} = useContext(SubscriptionDataContext);
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const reservedPlans = pickupFeaturedPlans(subscriptionPlans);
|
||||
const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus);
|
||||
|
||||
const { data: periodicUsage, isLoading } = useSWR(
|
||||
isCloud &&
|
||||
isDevFeaturesEnabled &&
|
||||
`/api/tenants/${currentTenantId}/subscription/periodic-usage`,
|
||||
async () =>
|
||||
cloudApi.get(`/api/tenants/:tenantId/subscription/periodic-usage`, {
|
||||
params: { tenantId: currentTenantId },
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||
<CurrentPlan subscription={currentSubscription} subscriptionPlan={currentPlan} />
|
||||
<CurrentPlan
|
||||
subscription={currentSubscription}
|
||||
subscriptionPlan={currentPlan}
|
||||
periodicUsage={periodicUsage}
|
||||
/>
|
||||
<PlanComparisonTable />
|
||||
<SwitchPlanActionBar
|
||||
currentSubscriptionPlanId={currentSubscription.planId}
|
||||
|
|
|
@ -2458,8 +2458,8 @@ importers:
|
|||
specifier: ^29.5.0
|
||||
version: 29.5.0
|
||||
'@logto/cloud':
|
||||
specifier: 0.2.5-a6cff75
|
||||
version: 0.2.5-a6cff75(zod@3.23.8)
|
||||
specifier: 0.2.5-9a1b047
|
||||
version: 0.2.5-9a1b047(zod@3.23.8)
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^4.0.0
|
||||
version: link:../toolkit/connector-kit
|
||||
|
@ -5080,6 +5080,10 @@ packages:
|
|||
resolution: {integrity: sha512-19MGifwYGxjQMPrm6monfoQyOp9UTL/chtZE0JugppNwvvLyqr3Nx0maCHuwrydLt0ImBSgVmPW1cJVvu2tVPg==}
|
||||
engines: {node: ^20.9.0}
|
||||
|
||||
'@logto/cloud@0.2.5-9a1b047':
|
||||
resolution: {integrity: sha512-1vOno0Gg5B6f2UOYW275e7zSwItNXJXJdwtcYLn9ThvRi3Lvers+TXDN4SQ/0+l39sjN/1WJ9ZCKvGH89AIGxA==}
|
||||
engines: {node: ^20.9.0}
|
||||
|
||||
'@logto/cloud@0.2.5-a6cff75':
|
||||
resolution: {integrity: sha512-VlW8MI8RU5dWbHOXY6HjcaC4cqN+I0FIplZQnQjsf00R7K1EFvWfdzNqMcPsiK0ljnyEkRBH4GO77zJ/MYsNdg==}
|
||||
engines: {node: ^20.9.0}
|
||||
|
@ -14722,6 +14726,13 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@logto/cloud@0.2.5-9a1b047(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@silverhand/essentials': 2.9.1
|
||||
'@withtyped/server': 0.13.6(zod@3.23.8)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@logto/cloud@0.2.5-a6cff75(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@silverhand/essentials': 2.9.1
|
||||
|
|
Loading…
Reference in a new issue