0
Fork 0
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:
Xiao Yijun 2023-07-19 18:18:47 +08:00 committed by GitHub
parent 8dbc3f6b3c
commit db87743ca1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 165 additions and 27 deletions

View file

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

View file

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

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

View file

@ -9,6 +9,7 @@
padding: _.unit(6);
margin: 0 _.unit(6);
box-shadow: var(--shadow-3);
overflow: hidden;
.header {
display: flex;

View file

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

View file

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

View file

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

View file

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

View file

@ -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 });
}}
/>
)
}
/>
);

View 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;
};