mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add dynamic rate limits (#2892)
This commit is contained in:
@@ -20,6 +20,8 @@ import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { ClaimLimitFields } from '../general/claim-limit-fields';
|
||||
|
||||
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||
|
||||
type SubscriptionClaimFormProps = {
|
||||
@@ -49,7 +51,14 @@ export const SubscriptionClaimForm = ({
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
recipientCount: subscriptionClaim.recipientCount,
|
||||
flags: subscriptionClaim.flags,
|
||||
documentRateLimits: subscriptionClaim.documentRateLimits,
|
||||
documentQuota: subscriptionClaim.documentQuota,
|
||||
emailRateLimits: subscriptionClaim.emailRateLimits,
|
||||
emailQuota: subscriptionClaim.emailQuota,
|
||||
apiRateLimits: subscriptionClaim.apiRateLimits,
|
||||
apiQuota: subscriptionClaim.apiQuota,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +154,30 @@ export const SubscriptionClaimForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
@@ -203,6 +236,8 @@ export const SubscriptionClaimForm = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
|
||||
|
||||
{formSubmitTrigger}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Control, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||
|
||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||
prefix?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ClaimLimitFields = <T extends FieldValues>({
|
||||
control,
|
||||
prefix = '',
|
||||
disabled,
|
||||
}: ClaimLimitFieldsProps<T>) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||
|
||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
value={field.value === null || field.value === undefined ? '' : field.value}
|
||||
placeholder={t`Unlimited`}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormLabel>
|
||||
<Trans>Limits</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{renderQuotaField(
|
||||
'documentQuota',
|
||||
<Trans>Monthly document quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField(
|
||||
'emailQuota',
|
||||
<Trans>Monthly email quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Progress } from '@documenso/ui/primitives/progress';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||
|
||||
type OrganisationUsagePanelProps = {
|
||||
organisationId: string;
|
||||
monthlyStats: Pick<OrganisationMonthlyStat, 'period' | 'documentCount' | 'emailCount' | 'apiCount'>[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
};
|
||||
|
||||
export const OrganisationUsagePanel = ({
|
||||
organisationId,
|
||||
monthlyStats,
|
||||
organisationClaim,
|
||||
}: OrganisationUsagePanelProps) => {
|
||||
const rows = [
|
||||
{
|
||||
counter: 'document' as const,
|
||||
label: <Trans>Documents</Trans>,
|
||||
used: monthlyStats[0]?.documentCount ?? 0,
|
||||
effectiveLimit: organisationClaim.documentQuota,
|
||||
},
|
||||
{
|
||||
counter: 'email' as const,
|
||||
label: <Trans>Emails</Trans>,
|
||||
used: monthlyStats[0]?.emailCount ?? 0,
|
||||
effectiveLimit: organisationClaim.emailQuota,
|
||||
},
|
||||
{
|
||||
counter: 'api' as const,
|
||||
label: <Trans>API requests</Trans>,
|
||||
used: monthlyStats[0]?.apiCount ?? 0,
|
||||
effectiveLimit: organisationClaim.apiQuota,
|
||||
},
|
||||
];
|
||||
|
||||
// Todo: This may not show if the organisation has no usage data for the current month.
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Usage for period: {monthlyStats[0]?.period || 'N/A'}</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => {
|
||||
const percent =
|
||||
row.effectiveLimit && row.effectiveLimit > 0
|
||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={row.counter} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{row.used} /{' '}
|
||||
{match(row.effectiveLimit)
|
||||
.with(null, () => <Trans>Unlimited</Trans>)
|
||||
.with(0, () => <Trans>Blocked</Trans>)
|
||||
.otherwise(String)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||
|
||||
{monthlyStats[0] && (
|
||||
<div className="flex w-full justify-end pt-1">
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
type OrganisationUsageResetButtonProps = {
|
||||
organisationId: string;
|
||||
counter: 'document' | 'email' | 'api';
|
||||
};
|
||||
|
||||
export const OrganisationUsageResetButton = ({ organisationId, counter }: OrganisationUsageResetButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutateAsync: reset, isPending } = trpc.admin.organisation.resetMonthlyStat.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({ title: t`Counter reset.` });
|
||||
await revalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t`Failed to reset counter.`, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isPending}
|
||||
onClick={() => reset({ organisationId, counter })}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
|
||||
type RateLimitEntryValue = { window: string; max: number };
|
||||
|
||||
type RateLimitArrayInputProps = {
|
||||
value: RateLimitEntryValue[];
|
||||
onChange: (value: RateLimitEntryValue[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||
const entries = value ?? [];
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
onChange(entries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...entries, { window: '5m', max: 100 }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-24"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add rate limit</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,9 @@ import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { OrganisationUsagePanel } from '~/components/general/organisation-usage-panel';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/organisations.$id';
|
||||
@@ -293,6 +295,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
@@ -565,7 +575,23 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
teamCount: organisation.organisationClaim.teamCount,
|
||||
memberCount: organisation.organisationClaim.memberCount,
|
||||
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||
recipientCount: organisation.organisationClaim.recipientCount,
|
||||
flags: organisation.organisationClaim.flags,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['documentRateLimits'],
|
||||
documentQuota: organisation.organisationClaim.documentQuota,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailRateLimits: organisation.organisationClaim.emailRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['emailRateLimits'],
|
||||
emailQuota: organisation.organisationClaim.emailQuota,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
apiRateLimits: organisation.organisationClaim.apiRateLimits as NonNullable<
|
||||
TUpdateOrganisationBillingFormSchema['claims']
|
||||
>['apiRateLimits'],
|
||||
apiQuota: organisation.organisationClaim.apiQuota,
|
||||
},
|
||||
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
||||
},
|
||||
@@ -745,6 +771,30 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
@@ -803,6 +853,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClaimLimitFields control={form.control} prefix="claims." />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
Reference in New Issue
Block a user