0
Fork 0
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:
Darcy Ye 2024-08-15 12:20:10 +08:00 committed by GitHub
parent db42279ed4
commit 87ff8cb8af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 148 additions and 36 deletions

View file

@ -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",

View file

@ -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']>;

View file

@ -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;

View file

@ -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]}`,

View file

@ -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,

View file

@ -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;

View file

@ -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
) {

View file

@ -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"

View file

@ -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}

View file

@ -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