diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ef2f1a5a..15c6dc877 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,6 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" } } diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index dc5956fab..e3cef8d92 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -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; 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 = ({ )} /> + ( + + + Recipient Count + + + field.onChange(parseInt(e.target.value, 10) || 0)} + /> + + + Maximum number of recipients per document allowed. 0 = Unlimited + + + + )} + /> +
Feature Flags @@ -203,6 +236,8 @@ export const SubscriptionClaimForm = ({ )}
+ + {formSubmitTrigger} diff --git a/apps/remix/app/components/general/claim-limit-fields.tsx b/apps/remix/app/components/general/claim-limit-fields.tsx new file mode 100644 index 000000000..ed29c8fc3 --- /dev/null +++ b/apps/remix/app/components/general/claim-limit-fields.tsx @@ -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 = { + control: Control; + /** e.g. '' for the claim form, 'claims.' for the org admin form. */ + prefix?: string; + disabled?: boolean; +}; + +export const ClaimLimitFields = ({ + control, + prefix = '', + disabled, +}: ClaimLimitFieldsProps) => { + const { t } = useLingui(); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const name = (key: string) => `${prefix}${key}` as Path; + + const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( + ( + + {label} + + field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} + /> + + {description} + + + )} + /> + ); + + const renderRateLimitField = (key: string, label: ReactNode) => ( + ( + + {label} + + + + + + )} + /> + ); + + return ( +
+ + Limits + + + {renderQuotaField( + 'documentQuota', + Monthly document quota, + Empty = Unlimited, 0 = Blocked, + )} + {renderRateLimitField('documentRateLimits', Document rate limits)} + + {renderQuotaField( + 'emailQuota', + Monthly email quota, + Empty = Unlimited, 0 = Blocked, + )} + {renderRateLimitField('emailRateLimits', Email rate limits)} + + {renderQuotaField('apiQuota', Monthly API quota, Empty = Unlimited, 0 = Blocked)} + {renderRateLimitField('apiRateLimits', API rate limits)} +
+ ); +}; diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx new file mode 100644 index 000000000..7936e539f --- /dev/null +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -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[]; + organisationClaim: OrganisationClaim; +}; + +export const OrganisationUsagePanel = ({ + organisationId, + monthlyStats, + organisationClaim, +}: OrganisationUsagePanelProps) => { + const rows = [ + { + counter: 'document' as const, + label: Documents, + used: monthlyStats[0]?.documentCount ?? 0, + effectiveLimit: organisationClaim.documentQuota, + }, + { + counter: 'email' as const, + label: Emails, + used: monthlyStats[0]?.emailCount ?? 0, + effectiveLimit: organisationClaim.emailQuota, + }, + { + counter: 'api' as const, + label: API requests, + 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 ( +
+
+

+ Usage for period: {monthlyStats[0]?.period || 'N/A'} +

+
+ + {rows.map((row) => { + const percent = + row.effectiveLimit && row.effectiveLimit > 0 + ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) + : 0; + + return ( +
+
+ {row.label} + + {row.used} /{' '} + {match(row.effectiveLimit) + .with(null, () => Unlimited) + .with(0, () => Blocked) + .otherwise(String)} + +
+ + {row.effectiveLimit && row.effectiveLimit > 0 ? : null} + + {monthlyStats[0] && ( +
+ +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx new file mode 100644 index 000000000..73340b122 --- /dev/null +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -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 ( + + ); +}; diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx new file mode 100644 index 000000000..a2adaad38 --- /dev/null +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -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) => { + 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 ( +
+ {entries.map((entry, index) => ( +
+ updateEntry(index, { window: e.target.value })} + /> + updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} + /> + +
+ ))} + + +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 64a6839f8..a0c5cf37a 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -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 + +
+ +
@@ -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 )} /> + ( + + + Recipient Count + + + field.onChange(parseInt(e.target.value, 10) || 0)} + /> + + + Maximum number of recipients per document allowed. 0 = Unlimited + + + + )} + /> +
Feature Flags @@ -803,6 +853,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin )}
+ +