mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +10:00
fix: admin organisation limits and usage UI
This commit is contained in:
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -20,6 +13,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LimitGroup = {
|
||||||
|
title: ReactNode;
|
||||||
|
quotaKey: string;
|
||||||
|
rateLimitKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const ClaimLimitFields = <T extends FieldValues>({
|
export const ClaimLimitFields = <T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
prefix = '',
|
prefix = '',
|
||||||
@@ -30,13 +29,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||||
|
|
||||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
const limitGroups: LimitGroup[] = [
|
||||||
|
{
|
||||||
|
title: <Trans>Documents</Trans>,
|
||||||
|
quotaKey: 'documentQuota',
|
||||||
|
rateLimitKey: 'documentRateLimits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>Emails</Trans>,
|
||||||
|
quotaKey: 'emailQuota',
|
||||||
|
rateLimitKey: 'emailRateLimits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>API</Trans>,
|
||||||
|
quotaKey: 'apiQuota',
|
||||||
|
rateLimitKey: 'apiRateLimits',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderQuotaField = (group: LimitGroup) => (
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.quotaKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Monthly quota</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -47,51 +66,51 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{description}</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
const renderRateLimitField = (group: LimitGroup) => (
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.rateLimitKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-3">
|
||||||
<FormLabel>
|
<div>
|
||||||
<Trans>Limits</Trans>
|
<h3 className="font-semibold text-base">
|
||||||
</FormLabel>
|
<Trans>Limits</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{renderQuotaField(
|
<div className="overflow-hidden rounded-lg border">
|
||||||
'documentQuota',
|
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||||
<Trans>Monthly document quota</Trans>,
|
{limitGroups.map((group) => (
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||||
)}
|
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
|
||||||
|
|
||||||
{renderQuotaField(
|
{renderQuotaField(group)}
|
||||||
'emailQuota',
|
{renderRateLimitField(group)}
|
||||||
<Trans>Monthly email quota</Trans>,
|
</div>
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
))}
|
||||||
)}
|
</div>
|
||||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
</div>
|
||||||
|
|
||||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
|
||||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { RotateCcwIcon } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
type OrganisationUsageResetButtonProps = {
|
type OrganisationUsageResetButtonProps = {
|
||||||
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
|||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => reset({ organisationId, counter })}
|
onClick={() => reset({ organisationId, counter })}
|
||||||
>
|
>
|
||||||
|
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
<Trans>Reset</Trans>
|
<Trans>Reset</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
|
|||||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
|
||||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
@@ -268,54 +267,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<GenericOrganisationAdminForm organisation={organisation} />
|
<GenericOrganisationAdminForm organisation={organisation} />
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<SettingsHeader
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
title={t`Organisation usage`}
|
||||||
<div>
|
subtitle={t`Current usage against organisation limits.`}
|
||||||
<p className="font-medium text-sm">
|
className="mt-6"
|
||||||
<Trans>Organisation usage</Trans>
|
hideDivider
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
|
||||||
<Trans>Current usage against organisation limits.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
<OrganisationUsagePanel
|
||||||
<DetailsCard label={<Trans>Members</Trans>}>
|
organisationId={organisation.id}
|
||||||
<DetailsValue>
|
monthlyStats={organisation.monthlyStats}
|
||||||
{organisation.members.length} /{' '}
|
organisationClaim={organisation.organisationClaim}
|
||||||
{organisation.organisationClaim.memberCount === 0
|
capacityUsage={{
|
||||||
? t`Unlimited`
|
members: organisation.members.length,
|
||||||
: organisation.organisationClaim.memberCount}
|
teams: organisation.teams.length,
|
||||||
</DetailsValue>
|
}}
|
||||||
</DetailsCard>
|
/>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
|
||||||
<DetailsValue>
|
|
||||||
{organisation.teams.length} /{' '}
|
|
||||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
|
||||||
</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">
|
<div className="mt-6 rounded-lg border p-4">
|
||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem value="global-settings" className="border-b-0">
|
<AccordionItem value="global-settings" className="border-b-0">
|
||||||
<AccordionTrigger className="py-0">
|
<AccordionTrigger className="py-0">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-sm">
|
<p className="font-semibold text-base">
|
||||||
<Trans>Global Settings</Trans>
|
<Trans>Global Settings</Trans>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-normal text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
<Trans>Default settings applied to this organisation.</Trans>
|
<Trans>Default settings applied to this organisation.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,21 +383,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<div className="mt-16 space-y-10">
|
<div className="mt-16 space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Members</Trans>
|
<Trans>Organisation Members</Trans>
|
||||||
</label>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>People with access to this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="my-2">
|
<div className="mt-3">
|
||||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Teams</Trans>
|
<Trans>Organisation Teams</Trans>
|
||||||
</label>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>Teams that belong to this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="my-2">
|
<div className="mt-3">
|
||||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -715,108 +698,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="claims.teamCount"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="claims.teamCount"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans>Team Count</Trans>
|
<FormLabel>
|
||||||
</FormLabel>
|
<Trans>Team Count</Trans>
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
type="number"
|
<Input
|
||||||
min={0}
|
type="number"
|
||||||
{...field}
|
min={0}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
{...field}
|
||||||
/>
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
</FormControl>
|
/>
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
<FormDescription>
|
||||||
</FormDescription>
|
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.memberCount"
|
name="claims.memberCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Member Count</Trans>
|
<Trans>Member Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.envelopeItemCount"
|
name="claims.envelopeItemCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Envelope Item Count</Trans>
|
<Trans>Envelope Item Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.recipientCount"
|
name="claims.recipientCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Recipient Count</Trans>
|
<Trans>Recipient Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
</FormLabel>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>Capabilities enabled for this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
<div className="mt-3 space-y-2 rounded-md border p-4">
|
||||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||||
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
|
import { isQuotaExceeded, isQuotaNearing, QUOTA_WARNING_THRESHOLD } from '../../universal/quota-usage';
|
||||||
|
|
||||||
|
export { QUOTA_WARNING_THRESHOLD };
|
||||||
|
|
||||||
export type QuotaFlags = {
|
export type QuotaFlags = {
|
||||||
isDocumentQuotaExceeded: boolean;
|
isDocumentQuotaExceeded: boolean;
|
||||||
@@ -22,39 +24,6 @@ type ComputeQuotaFlagsOptions = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
|
|
||||||
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
|
|
||||||
*/
|
|
||||||
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
|
||||||
if (quota === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quota === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage >= quota;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A counter is "nearing" its quota once usage reaches the warning threshold
|
|
||||||
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
|
|
||||||
* exceeded are mutually exclusive per counter.
|
|
||||||
*/
|
|
||||||
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
|
||||||
if (quota === null || quota === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isQuotaExceeded(quota, usage)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
||||||
return {
|
return {
|
||||||
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
import { QUOTA_WARNING_THRESHOLD } from '../../universal/quota-usage';
|
||||||
|
|
||||||
|
export { QUOTA_WARNING_THRESHOLD };
|
||||||
|
|
||||||
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,37 @@ import { z } from 'zod';
|
|||||||
*
|
*
|
||||||
* Example: "5m", "1h", "1d"
|
* Example: "5m", "1h", "1d"
|
||||||
*/
|
*/
|
||||||
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
|
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
|
||||||
|
|
||||||
export const ZRateLimitArraySchema = z.array(
|
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
|
||||||
z.object({
|
message: 'Use a duration with a unit, e.g. 5m, 1h, or 24h',
|
||||||
window: ZRateLimitWindowSchema,
|
});
|
||||||
max: z.number().int().positive(),
|
|
||||||
}),
|
export const ZRateLimitArraySchema = z
|
||||||
);
|
.array(
|
||||||
|
z.object({
|
||||||
|
window: ZRateLimitWindowSchema,
|
||||||
|
max: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.superRefine((entries, ctx) => {
|
||||||
|
const windows = new Map<string, number>();
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const window = entry.window.trim();
|
||||||
|
const duplicateIndex = windows.get(window);
|
||||||
|
|
||||||
|
if (duplicateIndex !== undefined) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Use a unique window for each rate limit',
|
||||||
|
path: [index, 'window'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.set(window, index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
||||||
|
|
||||||
@@ -52,7 +75,7 @@ export const ZClaimFlagsSchema = z.object({
|
|||||||
signingReminders: z.boolean().optional(),
|
signingReminders: z.boolean().optional(),
|
||||||
|
|
||||||
cscQesSigning: z.boolean().optional(),
|
cscQesSigning: z.boolean().optional(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls whether an organisation is prevented from sending emails.
|
* Controls whether an organisation is prevented from sending emails.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user