mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(console): apply quota limit for webhooks (#4186)
This commit is contained in:
parent
8dbc3f6b3c
commit
db87743ca1
10 changed files with 165 additions and 27 deletions
|
@ -0,0 +1,18 @@
|
|||
import { type ReactNode } from 'react';
|
||||
|
||||
import { contactEmailLink } from '@/consts';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function ContactUsPhraseLink({ children }: Props) {
|
||||
return (
|
||||
<TextLink href={contactEmailLink} target="_blank">
|
||||
{children}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactUsPhraseLink;
|
|
@ -0,0 +1,16 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(6);
|
||||
padding: _.unit(6);
|
||||
background-color: var(--color-info-container);
|
||||
margin: 0 _.unit(-6) _.unit(-6);
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
}
|
29
packages/console/src/components/QuotaGuardFooter/index.tsx
Normal file
29
packages/console/src/components/QuotaGuardFooter/index.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/ds-components/Button';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function QuotaGuardFooter({ children }: Props) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>{children}</div>
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
title="upsell.upgrade_plan"
|
||||
onClick={() => {
|
||||
navigate('/tenant-settings/subscription');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuotaGuardFooter;
|
|
@ -9,6 +9,7 @@
|
|||
padding: _.unit(6);
|
||||
margin: 0 _.unit(6);
|
||||
box-shadow: var(--shadow-3);
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
|
|
@ -3,14 +3,17 @@ import useSWR from 'swr';
|
|||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type Subscription } from '@/cloud/types/router';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { isCloud, isProduction } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
const useCurrentSubscription = () => {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const cloudApi = useCloudApi();
|
||||
return useSWR<Subscription, Error>(
|
||||
isCloud && `/api/tenants/${currentTenantId}/subscription`,
|
||||
/**
|
||||
* Todo: @xiaoyijun remove this condition on subscription features ready.
|
||||
*/
|
||||
!isProduction && isCloud && `/api/tenants/${currentTenantId}/subscription`,
|
||||
async () =>
|
||||
cloudApi.get('/api/tenants/:tenantId/subscription', {
|
||||
params: { tenantId: currentTenantId },
|
||||
|
|
|
@ -4,7 +4,7 @@ import useSWRImmutable from 'swr/immutable';
|
|||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { isCloud, isProduction } from '@/consts/env';
|
||||
import { reservedPlanIdOrder } from '@/consts/subscriptions';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
import { addSupportQuotaToPlan } from '@/utils/subscription';
|
||||
|
@ -12,7 +12,10 @@ import { addSupportQuotaToPlan } from '@/utils/subscription';
|
|||
const useSubscriptionPlans = () => {
|
||||
const cloudApi = useCloudApi();
|
||||
const useSwrResponse = useSWRImmutable<SubscriptionPlanResponse[], Error>(
|
||||
isCloud && '/api/subscription-plans',
|
||||
/**
|
||||
* Todo: @xiaoyijun remove this condition on subscription features ready.
|
||||
*/
|
||||
!isProduction && isCloud && '/api/subscription-plans',
|
||||
async () => cloudApi.get('/api/subscription-plans')
|
||||
);
|
||||
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import { type Hook, type CreateHook, type HookEvent, type HookConfig } from '@logto/schemas';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import Button from '@/ds-components/Button';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
|
||||
import { type BasicWebhookFormType } from '../../types';
|
||||
import BasicWebhookForm from '../BasicWebhookForm';
|
||||
|
||||
type Props = {
|
||||
totalWebhookCount: number;
|
||||
onClose: (createdHook?: Hook) => void;
|
||||
};
|
||||
|
||||
|
@ -20,7 +27,16 @@ type CreateHookPayload = Pick<CreateHook, 'name'> & {
|
|||
};
|
||||
};
|
||||
|
||||
function CreateForm({ onClose }: Props) {
|
||||
function CreateForm({ totalWebhookCount, onClose }: Props) {
|
||||
const { data: currentPlan } = useCurrentSubscriptionPlan();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const shouldBlockCreation = isOverQuota({
|
||||
quotaKey: 'hooksLimit',
|
||||
usage: totalWebhookCount,
|
||||
plan: currentPlan,
|
||||
});
|
||||
|
||||
const formMethods = useForm<BasicWebhookFormType>();
|
||||
const {
|
||||
handleSubmit,
|
||||
|
@ -50,14 +66,27 @@ function CreateForm({ onClose }: Props) {
|
|||
title="webhooks.create_form.title"
|
||||
subtitle="webhooks.create_form.subtitle"
|
||||
footer={
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="webhooks.create_form.create_webhook"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
shouldBlockCreation && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.hooks', { count: currentPlan.quota.hooksLimit })}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
) : (
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="webhooks.create_form.create_webhook"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
|
|
|
@ -7,10 +7,11 @@ import CreateForm from './CreateForm';
|
|||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
totalWebhookCount: number;
|
||||
onClose: (createdHook?: Hook) => void;
|
||||
};
|
||||
|
||||
function CreateFormModal({ isOpen, onClose }: Props) {
|
||||
function CreateFormModal({ isOpen, totalWebhookCount, onClose }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
shouldCloseOnOverlayClick
|
||||
|
@ -22,7 +23,7 @@ function CreateFormModal({ isOpen, onClose }: Props) {
|
|||
onClose();
|
||||
}}
|
||||
>
|
||||
<CreateForm onClose={onClose} />
|
||||
<CreateForm totalWebhookCount={totalWebhookCount} onClose={onClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -158,19 +158,22 @@ function Webhooks() {
|
|||
},
|
||||
}}
|
||||
widgets={
|
||||
<CreateFormModal
|
||||
isOpen={isCreateNew}
|
||||
onClose={(createdHook?: Hook) => {
|
||||
if (createdHook) {
|
||||
void mutate();
|
||||
toast.success(t('webhooks.webhook_created', { name: createdHook.name }));
|
||||
navigate(buildDetailsPathname(createdHook.id), { replace: true });
|
||||
return;
|
||||
}
|
||||
totalCount !== undefined && (
|
||||
<CreateFormModal
|
||||
isOpen={isCreateNew}
|
||||
totalWebhookCount={totalCount}
|
||||
onClose={(createdHook?: Hook) => {
|
||||
if (createdHook) {
|
||||
void mutate();
|
||||
toast.success(t('webhooks.webhook_created', { name: createdHook.name }));
|
||||
navigate(buildDetailsPathname(createdHook.id), { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ pathname: webhooksPathname, search });
|
||||
}}
|
||||
/>
|
||||
navigate({ pathname: webhooksPathname, search });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
35
packages/console/src/utils/quota.ts
Normal file
35
packages/console/src/utils/quota.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { isCloud, isProduction } from '@/consts/env';
|
||||
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||
|
||||
type IsOverQuotaParameters = {
|
||||
quotaKey: keyof SubscriptionPlanQuota;
|
||||
usage: number;
|
||||
plan?: SubscriptionPlan;
|
||||
};
|
||||
|
||||
export const isOverQuota = ({ quotaKey, usage, plan }: IsOverQuotaParameters) => {
|
||||
/**
|
||||
* Todo: @xiaoyijun remove this condition on subscription features ready.
|
||||
*/
|
||||
if (isProduction || !isCloud) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the plan is not loaded, guarded by backend APIs
|
||||
if (!plan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quotaValue = plan.quota[quotaKey];
|
||||
|
||||
// Unlimited
|
||||
if (quotaValue === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof quotaValue === 'boolean') {
|
||||
return !quotaValue;
|
||||
}
|
||||
|
||||
return usage >= quotaValue;
|
||||
};
|
Loading…
Reference in a new issue