0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor: move has{Reach,Surpass}Limit method to subscription context (#6664)

* refactor: move has{Reach,Surpass}Limit method to subscription context

* chore: update code
This commit is contained in:
Darcy Ye 2024-10-12 12:19:07 +08:00 committed by GitHub
parent c6f59cb0f8
commit 4dc1f82195
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 152 additions and 190 deletions

View file

@ -6,7 +6,6 @@ import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import SkuName from '@/components/SkuName';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = {
readonly isCreatingSocialConnector: boolean;
@ -18,15 +17,11 @@ function Footer({ isCreatingSocialConnector, isCreateButtonDisabled, onClickCrea
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' });
const {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionUsage,
currentSubscriptionQuota,
hasReachedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const isSocialConnectorsReachLimit = hasReachedSubscriptionQuotaLimit({
quotaKey: 'socialConnectorsLimit',
usage: currentSubscriptionUsage.socialConnectorsLimit,
quota: currentSubscriptionQuota,
});
const isSocialConnectorsReachLimit = hasReachedSubscriptionQuotaLimit('socialConnectorsLimit');
if (isCreatingSocialConnector && isSocialConnectorsReachLimit) {
return (

View file

@ -2,12 +2,17 @@ import { conditional, joinPath } from '@silverhand/essentials';
import { useContext, useRef } from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import { type NewSubscriptionCountBasedUsage } from '@/cloud/types/router';
import AppLoading from '@/components/AppLoading';
import Topbar from '@/components/Topbar';
import { isCloud } from '@/consts/env';
import SubscriptionDataProvider from '@/contexts/SubscriptionDataProvider';
import useNewSubscriptionData from '@/contexts/SubscriptionDataProvider/use-new-subscription-data';
import useSubscriptionData from '@/contexts/SubscriptionDataProvider/use-subscription-data';
import {
hasSurpassedSubscriptionQuotaLimit,
hasReachedSubscriptionQuotaLimit,
} from '@/contexts/SubscriptionDataProvider/utils';
import { TenantsContext } from '@/contexts/TenantsProvider';
import useScroll from '@/hooks/use-scroll';
import useUserPreferences from '@/hooks/use-user-preferences';
@ -42,9 +47,29 @@ export default function AppContent() {
return (
<SubscriptionDataProvider
subscriptionData={{
subscriptionDataAndUtils={{
...subscriptionDta,
...newSubscriptionData,
hasSurpassedSubscriptionQuotaLimit: <T extends keyof NewSubscriptionCountBasedUsage>(
quotaKey: T,
usage?: NewSubscriptionCountBasedUsage[T]
) =>
hasSurpassedSubscriptionQuotaLimit({
quotaKey,
usage,
subscriptionUsage: newSubscriptionData.currentSubscriptionUsage,
subscriptionQuota: newSubscriptionData.currentSubscriptionQuota,
}),
hasReachedSubscriptionQuotaLimit: <T extends keyof NewSubscriptionCountBasedUsage>(
quotaKey: T,
usage?: NewSubscriptionCountBasedUsage[T]
) =>
hasReachedSubscriptionQuotaLimit({
quotaKey,
usage,
subscriptionUsage: newSubscriptionData.currentSubscriptionUsage,
subscriptionQuota: newSubscriptionData.currentSubscriptionQuota,
}),
}}
>
<div className={styles.app}>

View file

@ -32,16 +32,18 @@ export const SubscriptionDataContext = createContext<FullContext>({
currentSubscriptionRoleScopeUsage: {},
mutateSubscriptionQuotaAndUsages: noop,
/* ==== For new pricing model ==== */
hasSurpassedSubscriptionQuotaLimit: () => false,
hasReachedSubscriptionQuotaLimit: () => false,
});
type Props = {
readonly subscriptionData: FullContext;
readonly subscriptionDataAndUtils: FullContext;
readonly children: ReactNode;
};
function SubscriptionDataProvider({ children, subscriptionData }: Props) {
function SubscriptionDataProvider({ children, subscriptionDataAndUtils }: Props) {
return (
<SubscriptionDataContext.Provider value={subscriptionData}>
<SubscriptionDataContext.Provider value={subscriptionDataAndUtils}>
{children}
</SubscriptionDataContext.Provider>
);

View file

@ -12,6 +12,13 @@ export type Context = {
onCurrentSubscriptionUpdated: (subscription?: Subscription) => void;
};
export type SubscriptionUsageOptions<T extends keyof NewSubscriptionCountBasedUsage> = {
quotaKey: T;
subscriptionUsage: NewSubscriptionCountBasedUsage;
subscriptionQuota: NewSubscriptionQuota;
usage?: NewSubscriptionCountBasedUsage[T];
};
type NewSubscriptionSupplementContext = {
logtoSkus: LogtoSkuResponse[];
currentSku: LogtoSkuResponse;
@ -23,6 +30,19 @@ type NewSubscriptionSupplementContext = {
mutateSubscriptionQuotaAndUsages: () => void;
};
type NewSubscriptionResourceStatus = {
hasSurpassedSubscriptionQuotaLimit: <T extends keyof NewSubscriptionCountBasedUsage>(
quotaKey: T,
usage?: NewSubscriptionCountBasedUsage[T]
) => boolean;
hasReachedSubscriptionQuotaLimit: <T extends keyof NewSubscriptionCountBasedUsage>(
quotaKey: T,
usage?: NewSubscriptionCountBasedUsage[T]
) => boolean;
};
export type NewSubscriptionContext = Context & NewSubscriptionSupplementContext;
export type FullContext = Context & NewSubscriptionSupplementContext;
export type FullContext = Context &
NewSubscriptionSupplementContext &
NewSubscriptionResourceStatus;

View file

@ -0,0 +1,43 @@
import { type NewSubscriptionCountBasedUsage } from '@/cloud/types/router';
import { isCloud } from '@/consts/env';
import { type SubscriptionUsageOptions } from './types';
/* === For new pricing model === */
const isSubscriptionUsageWithInLimit = <T extends keyof NewSubscriptionCountBasedUsage>(
{ quotaKey, subscriptionUsage, subscriptionQuota, usage }: SubscriptionUsageOptions<T>,
inclusive = true
) => {
// No limitations for OSS version
if (!isCloud) {
return true;
}
/**
* Sometimes we need to manually retrieve usage to overwrite the usage in subscriptionUsage.
* For example, for the usage of `scopesPerResourceLimit`, `subscriptionUsage.scopesPerResourceLimit` records the largest value among all resource scopes.
* However, when operating on resources in practice, we need to know the specific usage of scopes for the current resource. In this case, we need to manually calculate the value of scopes for the current resource before calling the function.
*/
const usageValue = usage ?? subscriptionUsage[quotaKey];
const quotaValue = subscriptionQuota[quotaKey];
// Unlimited
if (quotaValue === null) {
return true;
}
if (typeof quotaValue === 'boolean') {
return quotaValue;
}
return inclusive ? usageValue <= quotaValue : usageValue < quotaValue;
};
export const hasSurpassedSubscriptionQuotaLimit = <T extends keyof NewSubscriptionCountBasedUsage>(
options: SubscriptionUsageOptions<T>
) => !isSubscriptionUsageWithInLimit(options);
export const hasReachedSubscriptionQuotaLimit = <T extends keyof NewSubscriptionCountBasedUsage>(
options: SubscriptionUsageOptions<T>
) => !isSubscriptionUsageWithInLimit(options, false);
/* === For new pricing model === */

View file

@ -1,34 +1,14 @@
import { useContext, useMemo } from 'react';
import { useContext } from 'react';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import {
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useApiResourcesUsage = () => {
const { currentSubscriptionQuota, currentSubscriptionUsage } =
const { hasReachedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit } =
useContext(SubscriptionDataContext);
const hasReachedLimit = useMemo(
() =>
hasReachedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
);
const hasReachedLimit = hasReachedSubscriptionQuotaLimit('resourcesLimit');
const hasSurpassedLimit = useMemo(
() =>
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'resourcesLimit',
usage: currentSubscriptionUsage.resourcesLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.resourcesLimit]
);
const hasSurpassedLimit = hasSurpassedSubscriptionQuotaLimit('resourcesLimit');
return {
hasReachedLimit,

View file

@ -1,54 +1,22 @@
import { useContext, useMemo } from 'react';
import { useContext } from 'react';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import {
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useApplicationsUsage = () => {
const { currentSubscriptionQuota, currentSubscriptionUsage } =
const { hasReachedSubscriptionQuotaLimit, hasSurpassedSubscriptionQuotaLimit } =
useContext(SubscriptionDataContext);
const hasMachineToMachineAppsReachedLimit = useMemo(
() =>
hasReachedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
const hasMachineToMachineAppsReachedLimit =
hasReachedSubscriptionQuotaLimit('machineToMachineLimit');
const hasMachineToMachineAppsSurpassedLimit =
hasSurpassedSubscriptionQuotaLimit('machineToMachineLimit');
const hasThirdPartyAppsReachedLimit = hasReachedSubscriptionQuotaLimit(
'thirdPartyApplicationsLimit'
);
const hasMachineToMachineAppsSurpassedLimit = useMemo(
() =>
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'machineToMachineLimit',
usage: currentSubscriptionUsage.machineToMachineLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.machineToMachineLimit, currentSubscriptionQuota]
);
const hasThirdPartyAppsReachedLimit = useMemo(
() =>
hasReachedSubscriptionQuotaLimit({
quotaKey: 'thirdPartyApplicationsLimit',
usage: currentSubscriptionUsage.thirdPartyApplicationsLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.thirdPartyApplicationsLimit, currentSubscriptionQuota]
);
const hasAppsReachedLimit = useMemo(
() =>
hasReachedSubscriptionQuotaLimit({
quotaKey: 'applicationsLimit',
usage: currentSubscriptionUsage.applicationsLimit,
quota: currentSubscriptionQuota,
}),
[currentSubscriptionUsage.applicationsLimit, currentSubscriptionQuota]
);
const hasAppsReachedLimit = hasReachedSubscriptionQuotaLimit('applicationsLimit');
return {
hasMachineToMachineAppsReachedLimit,

View file

@ -16,7 +16,6 @@ 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 { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = {
readonly resourceId: string;
@ -32,6 +31,7 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
currentSubscriptionQuota,
currentSubscriptionResourceScopeUsage,
currentSubscription: { planId, isEnterprisePlan },
hasReachedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -57,11 +57,10 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
})
);
const isScopesPerResourceReachLimit = hasReachedSubscriptionQuotaLimit({
quotaKey: 'scopesPerResourceLimit',
usage: currentSubscriptionResourceScopeUsage[resourceId] ?? 0,
quota: currentSubscriptionQuota,
});
const isScopesPerResourceReachLimit = hasReachedSubscriptionQuotaLimit(
'scopesPerResourceLimit',
currentSubscriptionResourceScopeUsage[resourceId] ?? 0
);
return (
<ReactModal

View file

@ -14,7 +14,6 @@ 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 { hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = {
readonly roleId: string;
@ -28,6 +27,7 @@ function AssignPermissionsModal({ roleId, roleType, onClose }: Props) {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionRoleScopeUsage,
currentSubscriptionQuota,
hasSurpassedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopes, setScopes] = useState<ScopeResponse[]>([]);
@ -52,11 +52,10 @@ function AssignPermissionsModal({ roleId, roleType, onClose }: Props) {
}
};
const shouldBlockScopeAssignment = hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'scopesPerRoleLimit',
usage: (currentSubscriptionRoleScopeUsage[roleId] ?? 0) + scopes.length,
quota: currentSubscriptionQuota,
});
const shouldBlockScopeAssignment = hasSurpassedSubscriptionQuotaLimit(
'scopesPerRoleLimit',
(currentSubscriptionRoleScopeUsage[roleId] ?? 0) + scopes.length
);
return (
<ReactModal

View file

@ -1,4 +1,4 @@
import { RoleType } from '@logto/schemas';
import { RoleType, type ScopeResponse } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@ -7,39 +7,31 @@ import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import SkuName from '@/components/SkuName';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import {
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
type Props = {
readonly roleType: RoleType;
readonly scopes?: ScopeResponse[];
readonly isCreating: boolean;
readonly onClickCreate: () => void;
};
function Footer({ roleType, isCreating, onClickCreate }: Props) {
function Footer({ roleType, scopes, isCreating, onClickCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota,
currentSubscriptionUsage,
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const hasRoleReachedLimit = hasReachedSubscriptionQuotaLimit({
quotaKey: roleType === RoleType.User ? 'userRolesLimit' : 'machineToMachineRolesLimit',
usage:
roleType === RoleType.User
? currentSubscriptionUsage.userRolesLimit
: currentSubscriptionUsage.machineToMachineRolesLimit,
quota: currentSubscriptionQuota,
});
const hasRoleReachedLimit = hasReachedSubscriptionQuotaLimit(
roleType === RoleType.User ? 'userRolesLimit' : 'machineToMachineRolesLimit'
);
const hasScopesPerRoleSurpassedLimit = hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'scopesPerRoleLimit',
usage: currentSubscriptionUsage.scopesPerRoleLimit,
quota: currentSubscriptionQuota,
});
const hasScopesPerRoleSurpassedLimit = hasSurpassedSubscriptionQuotaLimit(
'scopesPerRoleLimit',
scopes?.length ?? 0
);
if (hasRoleReachedLimit || hasScopesPerRoleSurpassedLimit) {
return (

View file

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

View file

@ -19,7 +19,6 @@ import TextLink from '@/ds-components/TextLink';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useUserPreferences from '@/hooks/use-user-preferences';
import modalStyles from '@/scss/modal.module.scss';
import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
import { isPaidPlan } from '@/utils/subscription';
import InviteEmailsInput from '../InviteEmailsInput';
@ -44,9 +43,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const { show } = useConfirmModal();
const {
currentSubscription: { planId, isAddOnAvailable, isEnterprisePlan },
currentSubscriptionQuota,
currentSubscriptionUsage: { tenantMembersLimit },
mutateSubscriptionQuotaAndUsages,
hasReachedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const {
data: { tenantMembersUpsellNoticeAcknowledged },
@ -82,11 +80,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
[t]
);
const hasTenantMembersReachedLimit = hasReachedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
usage: tenantMembersLimit,
quota: currentSubscriptionQuota,
});
const hasTenantMembersReachedLimit = hasReachedSubscriptionQuotaLimit('tenantMembersLimit');
const onSubmit = handleSubmit(async ({ emails, role }) => {
if (role === TenantRole.Admin) {

View file

@ -1,38 +1,22 @@
import { useContext, useMemo } from 'react';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import {
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} from '@/utils/quota';
const useTenantMembersUsage = () => {
const { currentSubscriptionUsage, currentSubscriptionQuota } =
useContext(SubscriptionDataContext);
const {
currentSubscriptionUsage,
currentSubscriptionQuota,
hasReachedSubscriptionQuotaLimit,
hasSurpassedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const usage = useMemo(() => {
return currentSubscriptionUsage.tenantMembersLimit;
}, [currentSubscriptionUsage.tenantMembersLimit]);
const hasTenantMembersReachedLimit = useMemo(
() =>
hasReachedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
);
const hasTenantMembersReachedLimit = hasReachedSubscriptionQuotaLimit('tenantMembersLimit');
const hasTenantMembersSurpassedLimit = useMemo(
() =>
hasSurpassedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
quota: currentSubscriptionQuota,
usage: currentSubscriptionUsage.tenantMembersLimit,
}),
[currentSubscriptionQuota, currentSubscriptionUsage.tenantMembersLimit]
);
const hasTenantMembersSurpassedLimit = hasSurpassedSubscriptionQuotaLimit('tenantMembersLimit');
return {
hasTenantMembersReachedLimit,

View file

@ -12,7 +12,6 @@ import Button from '@/ds-components/Button';
import ModalLayout from '@/ds-components/ModalLayout';
import useApi from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form';
import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
type Props = {
readonly onClose: (createdHook?: Hook) => void;
@ -28,16 +27,12 @@ type CreateHookPayload = Pick<CreateHook, 'name'> & {
function CreateForm({ onClose }: Props) {
const {
currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota,
currentSubscriptionUsage,
hasReachedSubscriptionQuotaLimit,
} = useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const shouldBlockCreation = hasReachedSubscriptionQuotaLimit({
quotaKey: 'hooksLimit',
usage: currentSubscriptionUsage.hooksLimit,
quota: currentSubscriptionQuota,
});
const shouldBlockCreation = hasReachedSubscriptionQuotaLimit('hooksLimit');
const formMethods = useForm<BasicWebhookFormType>();
const {

View file

@ -1,39 +0,0 @@
import { type NewSubscriptionQuota } from '@/cloud/types/router';
import { isCloud } from '@/consts/env';
/* === For new pricing model === */
type SubscriptionUsageOptions = {
quotaKey: keyof NewSubscriptionQuota;
usage: number;
quota: NewSubscriptionQuota;
};
const isSubscriptionUsageWithInLimit = (
{ quotaKey, usage, quota }: SubscriptionUsageOptions,
inclusive = true
) => {
// No limitations for OSS version
if (!isCloud) {
return true;
}
const quotaValue = quota[quotaKey];
// Unlimited
if (quotaValue === null) {
return true;
}
if (typeof quotaValue === 'boolean') {
return quotaValue;
}
return inclusive ? usage <= quotaValue : usage < quotaValue;
};
export const hasSurpassedSubscriptionQuotaLimit = (options: SubscriptionUsageOptions) =>
!isSubscriptionUsageWithInLimit(options);
export const hasReachedSubscriptionQuotaLimit = (options: SubscriptionUsageOptions) =>
!isSubscriptionUsageWithInLimit(options, false);
/* === For new pricing model === */