By deleting this document, the following will occur:
diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
index daac27df1..ed495f83a 100644
--- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -1,13 +1,15 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
+import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
-import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -31,12 +33,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
-import { InfoIcon } from 'lucide-react';
+import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
+import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type EnvelopeDistributeDialogProps = {
onDistribute?: () => Promise;
@@ -66,7 +69,7 @@ export const EnvelopeDistributeDialog = ({
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const { toast } = useToast();
- const { t } = useLingui();
+ const { t, i18n } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
@@ -136,6 +139,27 @@ export const EnvelopeDistributeDialog = ({
});
}, [recipientsWithIndex, envelope.authOptions]);
+ /**
+ * Whether any fields significantly overlap each other. This is surfaced as a
+ * non-blocking warning since overlapping fields still allow sending, but can
+ * complicate the signing process or cause fields to behave unexpectedly.
+ */
+ const hasOverlappingEnvelopeFields = useMemo(
+ () =>
+ hasOverlappingFields(
+ envelope.fields.map((field) => ({
+ id: field.id,
+ envelopeItemId: field.envelopeItemId,
+ page: field.page,
+ positionX: Number(field.positionX),
+ positionY: Number(field.positionY),
+ width: Number(field.width),
+ height: Number(field.height),
+ })),
+ ),
+ [envelope.fields],
+ );
+
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -174,9 +198,13 @@ export const EnvelopeDistributeDialog = ({
setIsOpen(false);
} catch (err) {
+ const error = AppError.parseError(err);
+
+ const errorMessage = getDistributeErrorMessage(error.code);
+
toast({
- title: t`Something went wrong`,
- description: t`This envelope could not be distributed at this time. Please try again.`,
+ title: i18n._(errorMessage.title),
+ description: i18n._(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
@@ -200,6 +228,11 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
+ // Default the distribution method tab to the envelope's configured setting.
+ if (isOpen && envelope.documentMeta) {
+ setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
+ }
+
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -229,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
-
-
- );
-}
diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx
index 5baa48584..48507fb7d 100644
--- a/apps/remix/app/components/dialogs/template-use-dialog.tsx
+++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx
@@ -4,7 +4,7 @@ import {
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
@@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
-import { match } from 'ts-pattern';
import * as z from 'zod';
+import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
@@ -180,22 +180,11 @@ export function TemplateUseDialog({
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
-
- const errorMessage = match(error.code)
- .with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
- .with(
- AppErrorCode.INVALID_BODY,
- AppErrorCode.INVALID_REQUEST,
- () =>
- msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
- )
- .with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
- .with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
- .otherwise(() => msg`An error occurred while creating document from template.`);
+ const errorMessage = getTemplateUseErrorMessage(error.code);
toast({
- title: _(msg`Error`),
- description: _(errorMessage),
+ title: _(errorMessage.title),
+ description: _(errorMessage.description),
variant: 'destructive',
});
}
diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx
index e0e37d35c..20a0a23fa 100644
--- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx
+++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx
@@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
+import { AppError } from '@documenso/lib/errors/app-error';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
+import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
@@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({
);
}
+ const error = AppError.parseError(err);
+ const errorMessage = getDirectTemplateErrorMessage(error.code);
+
toast({
- title: _(msg`Something went wrong`),
- description: _(msg`We were unable to submit this document at this time. Please try again later.`),
+ title: _(errorMessage.title),
+ description: _(errorMessage.description),
variant: 'destructive',
});
}
diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx
new file mode 100644
index 000000000..2e20fdb59
--- /dev/null
+++ b/apps/remix/app/components/forms/email-transport-form.tsx
@@ -0,0 +1,317 @@
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+const ZEmailTransportFormSchema = z.object({
+ name: z.string().min(1),
+ fromName: z.string().min(1),
+ fromAddress: z.string().email(),
+ type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
+ host: z.string().optional(),
+ port: z.coerce.number().int().positive().optional(),
+ secure: z.boolean().optional(),
+ ignoreTLS: z.boolean().optional(),
+ username: z.string().optional(),
+ password: z.string().optional(),
+ service: z.string().optional(),
+ apiKey: z.string().optional(),
+ apiKeyUser: z.string().optional(),
+ endpoint: z.string().optional(),
+});
+
+export type EmailTransportFormValues = z.infer;
+
+type EmailTransportFormProps = {
+ defaultValues?: Partial;
+ isEdit?: boolean;
+ onFormSubmit: (values: EmailTransportFormValues) => Promise;
+ formSubmitTrigger?: React.ReactNode;
+};
+
+export const EmailTransportForm = ({
+ defaultValues,
+ isEdit = false,
+ onFormSubmit,
+ formSubmitTrigger,
+}: EmailTransportFormProps) => {
+ const { t } = useLingui();
+
+ const form = useForm({
+ resolver: zodResolver(ZEmailTransportFormSchema),
+ defaultValues: {
+ name: '',
+ fromName: '',
+ fromAddress: '',
+ type: 'SMTP_AUTH',
+ secure: false,
+ ignoreTLS: false,
+ ...defaultValues,
+ },
+ });
+
+ const type = form.watch('type');
+ const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined;
+
+ return (
+
+
+ );
+};
+
+/**
+ * Maps flat form values to the tRPC `config` discriminated union.
+ */
+export const emailTransportFormToConfig = (values: EmailTransportFormValues) => {
+ switch (values.type) {
+ case 'SMTP_AUTH':
+ return {
+ type: 'SMTP_AUTH' as const,
+ host: values.host ?? '',
+ port: values.port ?? 587,
+ secure: values.secure ?? false,
+ ignoreTLS: values.ignoreTLS ?? false,
+ username: values.username || undefined,
+ password: values.password || undefined,
+ service: values.service || undefined,
+ };
+ case 'SMTP_API':
+ return {
+ type: 'SMTP_API' as const,
+ host: values.host ?? '',
+ port: values.port ?? 587,
+ secure: values.secure ?? false,
+ apiKey: values.apiKey || '',
+ apiKeyUser: values.apiKeyUser || undefined,
+ };
+ case 'RESEND':
+ return { type: 'RESEND' as const, apiKey: values.apiKey || '' };
+ case 'MAILCHANNELS':
+ return {
+ type: 'MAILCHANNELS' as const,
+ apiKey: values.apiKey || '',
+ endpoint: values.endpoint || undefined,
+ };
+ }
+};
diff --git a/apps/remix/app/components/forms/public-profile-form.tsx b/apps/remix/app/components/forms/public-profile-form.tsx
index 1e9cd32ef..d354368ba 100644
--- a/apps/remix/app/components/forms/public-profile-form.tsx
+++ b/apps/remix/app/components/forms/public-profile-form.tsx
@@ -197,7 +197,9 @@ export const PublicProfileForm = ({ className, profile, onProfileUpdate }: Publi
return (
- Bio
+
+ Bio
+
diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx
index dc5956fab..1dd903a3f 100644
--- a/apps/remix/app/components/forms/subscription-claim-form.tsx
+++ b/apps/remix/app/components/forms/subscription-claim-form.tsx
@@ -1,5 +1,6 @@
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
+import { trpc } from '@documenso/trpc/react';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -13,6 +14,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
@@ -20,6 +22,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,10 +53,22 @@ 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,
+ emailTransportId: subscriptionClaim.emailTransportId ?? null,
},
});
+ const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
+ const transports = transportsData?.data ?? [];
+ const NONE_VALUE = '__none__';
+
return (
diff --git a/apps/remix/app/components/general/billing-plans.tsx b/apps/remix/app/components/general/billing-plans.tsx
index 538471b49..1de96f1d0 100644
--- a/apps/remix/app/components/general/billing-plans.tsx
+++ b/apps/remix/app/components/general/billing-plans.tsx
@@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
if (plan[interval] && plan[interval].isVisibleInApp) {
prices.push({
...plan[interval],
- memberCount: plan.memberCount,
claim: plan.id,
});
}
@@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
Subscribe
) : (
-
+
)}
@@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
);
};
-const BillingDialog = ({
- priceId,
- planName,
- claim,
-}: {
- priceId: string;
- planName: string;
- memberCount: number;
- claim: string;
-}) => {
+const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLingui();
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/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
index 1cdb03276..9f6ea47dc 100644
--- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx
+++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
@@ -1,4 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -12,11 +13,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react';
-import { useNavigate, useSearchParams } from 'react-router';
+import { useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
+import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form';
import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form';
@@ -35,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient,
directTemplateToken,
}: DirectTemplatePageViewProps) => {
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { _ } = useLingui();
@@ -117,12 +118,15 @@ export const DirectTemplatePageView = ({
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
- await navigate(`/sign/${token}/complete`);
+ window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
+ const error = AppError.parseError(err);
+ const errorMessage = getDirectTemplateErrorMessage(error.code);
+
toast({
- title: _(msg`Something went wrong`),
- description: _(msg`We were unable to submit this document at this time. Please try again later.`),
+ title: _(errorMessage.title),
+ description: _(errorMessage.description),
variant: 'destructive',
});
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
new file mode 100644
index 000000000..0e2bb6fb2
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
@@ -0,0 +1,68 @@
+import { AppErrorCode } from '@documenso/lib/errors/app-error';
+import { Button } from '@documenso/ui/primitives/button';
+import { Trans } from '@lingui/react/macro';
+import { AlertTriangleIcon } from 'lucide-react';
+
+export type CscRecipientBlockedPageProps = {
+ code: string;
+ recipientToken: string;
+};
+
+/**
+ * Terminal page rendered when the service-scope CSC OAuth callback surfaces a
+ * hard error the recipient can't resolve themselves (empty credential list,
+ * invalid cert, refused algorithm). The blocking-error cookie is read +
+ * cleared by the loader; this page only renders the message + retry CTA.
+ *
+ * The retry link kicks a fresh service-scope OAuth round-trip — useful when
+ * the TSP-side issue is transient (e.g. the recipient's admin has since
+ * provisioned a credential).
+ */
+export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
+ const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
+
+ return (
+
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+ No signing credentials available
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+ Signing certificate is invalid
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+ Signing algorithm is not supported
+ ) : (
+ Unable to start the signing flow
+ )}
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+
+ Your signing provider returned no usable credentials for this account. Contact your administrator or signing
+ provider for assistance.
+
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+
+ Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
+ signing provider for assistance.
+
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+
+ Your signing provider does not advertise a signing algorithm this document accepts. Contact your
+ administrator or signing provider for assistance.
+
+ ) : (
+ Something went wrong while preparing the remote signature. Please try again.
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
new file mode 100644
index 000000000..1aa091b35
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
@@ -0,0 +1,105 @@
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { Trans } from '@lingui/react/macro';
+import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+export type CscRecipientSigningInProgressPageProps = {
+ sessionId: string;
+ recipientToken: string;
+};
+
+/**
+ * Rendered when the credential-scope OAuth callback has attached a SAD to the
+ * server-side `CscSession` and set the `csc_sad_session` cookie. The page
+ * auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
+ * completion page on success. On failure, it surfaces an error message and
+ * a retry CTA pointing at a fresh credential-scope OAuth round-trip.
+ */
+export const CscRecipientSigningInProgressPage = ({
+ sessionId,
+ recipientToken,
+}: CscRecipientSigningInProgressPageProps) => {
+ const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
+
+ const [error, setError] = useState(null);
+
+ // Ref rather than state for the fire-once guard. Refs mutate synchronously,
+ // so React StrictMode's double-invoke of the effect sees the updated value
+ // on the second pass and short-circuits. A useState guard would still let
+ // the second effect fire because the queued setState from the first run
+ // hasn't been committed yet when the second one reads it — that double-fire
+ // races two signEnvelope calls; whichever loses sees the SAD already
+ // consumed and flashes "Signing failed" before the winning call's
+ // navigation kicks in.
+ const hasFiredRef = useRef(false);
+
+ useEffect(() => {
+ if (hasFiredRef.current) {
+ return;
+ }
+
+ hasFiredRef.current = true;
+
+ const run = async () => {
+ try {
+ await signEnvelope({ sessionId, recipientToken });
+
+ window.location.href = `/sign/${recipientToken}/complete`;
+ } catch (err) {
+ const parsed = AppError.parseError(err);
+ setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
+ }
+ };
+
+ void run();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
+
+ return (
+
+ {error ? (
+ <>
+
+
+
+ Signing failed
+
+
+
+ {error === AppErrorCode.CSC_TSP_TIMEOUT ? (
+ The signing provider did not respond in time. Please retry.
+ ) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
+
+ Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
+
+ ) : (
+ Something went wrong while applying your signature. Please retry.
+ )}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ Applying your signature
+
+
+
+ Please don't close this tab. The signing provider is finalising your signature.
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
index b331b7628..1979c63a2 100644
--- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
+++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
-import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
@@ -50,6 +49,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
+type DocumentSigningBranding = {
+ brandingEnabled: boolean;
+ brandingLogo: string;
+};
+
export type DocumentSigningPageViewV1Props = {
recipient: RecipientWithFields;
document: DocumentAndSender;
@@ -57,6 +61,7 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
+ branding: DocumentSigningBranding;
includeSenderDetails: boolean;
};
@@ -68,6 +73,7 @@ export const DocumentSigningPageViewV1 = ({
isRecipientsTurn,
allRecipients = [],
includeSenderDetails,
+ branding,
}: DocumentSigningPageViewV1Props) => {
const { documentData, documentMeta } = document;
@@ -77,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
- const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id);
@@ -122,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
- await navigate(`/sign/${recipient.token}/complete`);
+ window.location.href = `/sign/${recipient.token}/complete`;
}
};
@@ -168,10 +173,12 @@ export const DocumentSigningPageViewV1 = ({
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
+ const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
+
return (
- {document.team.teamGlobalSettings.brandingEnabled && document.team.teamGlobalSettings.brandingLogo && (
+ {hasCustomBrandingLogo && (

(
))
.with({ isComplete: false }, () => (
diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
index e18852d95..668a0cd2e 100644
--- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
@@ -19,6 +19,7 @@ import {
Download,
Edit,
FileOutputIcon,
+ History,
Loader,
MoreHorizontal,
Pencil,
@@ -29,10 +30,10 @@ import {
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
-import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
+import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
@@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url);
- const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
-
return (
@@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
)}
-
+ {canManageDocument && (
+ e.preventDefault()}>
+
+
+ Resend
+
+
+ }
+ />
+ )}
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
+ CANCELLED: {
+ label: msg`Cancelled`,
+ labelExtended: msg`Document cancelled`,
+ icon: XCircle,
+ color: 'text-red-500 dark:text-red-300',
+ },
EXPIRED: {
label: msg`Expired`,
labelExtended: msg`Document expired`,
diff --git a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx
index 225e13cd0..c53cd93c0 100644
--- a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx
+++ b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx
@@ -3,7 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { AppError } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
@@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client';
import { useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
-import { match } from 'ts-pattern';
import { useCurrentTeam } from '~/providers/team';
+import { getUploadErrorMessage } from '~/utils/toast-error-messages';
export type DocumentUploadButtonLegacyProps = {
className?: string;
@@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
console.error(err);
- const errorMessage = match(error.code)
- .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
- .with(
- AppErrorCode.LIMIT_EXCEEDED,
- () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
- )
- .with(
- 'ENVELOPE_ITEM_LIMIT_EXCEEDED',
- () => msg`You have reached the limit of the number of files per envelope.`,
- )
- .with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
- .with(
- 'CONVERSION_SERVICE_UNAVAILABLE',
- () => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
- )
- .with(
- 'CONVERSION_FAILED',
- () => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
- )
- .otherwise(() => msg`An error occurred while uploading your document.`);
+ const errorMessage = getUploadErrorMessage(error.code);
toast({
- title: _(msg`Error`),
- description: _(errorMessage),
+ title: _(errorMessage.title),
+ description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
index 7573d5fb4..db8f6ffbb 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx
@@ -1,3 +1,4 @@
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
@@ -13,14 +14,24 @@ import {
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
+import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
-import { CommandDialog } from '@documenso/ui/primitives/command';
+import {
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@documenso/ui/primitives/command';
+import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
-import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
+import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
@@ -53,6 +64,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
+ /**
+ * Debounce the fields used for overlap highlighting so we don't recompute on every
+ * small drag/resize tick. Overlaps only occur within the same page and envelope
+ * item, so computing from this page's fields alone is sufficient.
+ */
+ const debouncedPageFields = useDebouncedValue(localPageFields, 300);
+
+ const overlappingFieldFormIds = useMemo(() => {
+ const formIds = new Set();
+
+ const pairs = getOverlappingFieldPairs(
+ debouncedPageFields.map((field) => ({
+ id: field.formId,
+ envelopeItemId: field.envelopeItemId,
+ page: field.page,
+ positionX: field.positionX,
+ positionY: field.positionY,
+ width: field.width,
+ height: field.height,
+ })),
+ );
+
+ for (const pair of pairs) {
+ formIds.add(pair.fieldA.id);
+ formIds.add(pair.fieldB.id);
+ }
+
+ return formIds;
+ }, [debouncedPageFields]);
+
const handleResizeOrMove = (event: KonvaEventObject) => {
const isDragEvent = event.type === 'dragend';
@@ -104,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
pageLayer.current?.batchDraw();
};
+ /**
+ * Draws (or removes) a dashed warning outline over a field that significantly
+ * overlaps another field. The highlight is a child of the field group so it moves
+ * and resizes with the field, and sits on top of the field's own rect (which is
+ * re-styled on every render and would otherwise clobber a direct stroke change).
+ */
+ const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
+ const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
+
+ // Skip while a field is actively being dragged/resized. The highlight is driven
+ // by debounced field data, so it would lag behind and distort during the gesture.
+ // It is repainted once the gesture settles (the effect re-runs on isFieldChanging).
+ if (isFieldChanging) {
+ existingHighlight?.destroy();
+ return;
+ }
+
+ if (!isOverlapping) {
+ existingHighlight?.destroy();
+ return;
+ }
+
+ const fieldRect = fieldGroup.findOne('.field-rect');
+
+ if (!fieldRect) {
+ return;
+ }
+
+ const highlightAttrs = {
+ x: 0,
+ y: 0,
+ width: fieldRect.width(),
+ height: fieldRect.height(),
+ stroke: '#f59e0b',
+ strokeWidth: 2,
+ dash: [6, 4],
+ cornerRadius: 2,
+ strokeScaleEnabled: false,
+ listening: false,
+ } satisfies Partial;
+
+ if (existingHighlight instanceof Konva.Rect) {
+ existingHighlight.setAttrs(highlightAttrs);
+ existingHighlight.moveToTop();
+ return;
+ }
+
+ const highlight = new Konva.Rect({
+ name: 'field-overlap-highlight',
+ ...highlightAttrs,
+ });
+
+ fieldGroup.add(highlight);
+ highlight.moveToTop();
+ };
+
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
@@ -130,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
mode: 'edit',
});
+ syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
+
if (!isFieldEditable) {
return;
}
@@ -426,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
- }, [localPageFields, selectedKonvaFieldGroups]);
+ }, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -470,6 +569,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
};
+ const changeSelectedFieldsType = (type: FieldType) => {
+ const fields = selectedKonvaFieldGroups
+ .map((field) => editorFields.getFieldByFormId(field.id()))
+ .filter((field) => field !== undefined);
+
+ for (const field of fields) {
+ if (field.type !== type) {
+ editorFields.updateFieldByFormId(field.formId, {
+ type,
+ fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
+ id: undefined,
+ });
+ }
+ }
+ };
+
const duplicatedSelectedFields = () => {
const fields = selectedKonvaFieldGroups
.map((field) => editorFields.getFieldByFormId(field.id()))
@@ -554,6 +669,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
+ handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
@@ -602,6 +718,7 @@ type FieldActionButtonsProps = React.HTMLAttributes & {
handleDuplicateSelectedFieldsOnAllPages: () => void;
handleDeleteSelectedFields: () => void;
handleChangeRecipient: (recipientId: number) => void;
+ handleChangeFieldType: (type: FieldType) => void;
selectedFieldFormId: string[];
};
@@ -610,15 +727,40 @@ const FieldActionButtons = ({
handleDuplicateSelectedFieldsOnAllPages,
handleDeleteSelectedFields,
handleChangeRecipient,
+ handleChangeFieldType,
selectedFieldFormId,
...props
}: FieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
+ const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
+ /**
+ * Decide the preselected field type in the command input.
+ *
+ * If all fields share the same type, use that as the default selection.
+ * Otherwise show no preselection.
+ */
+ const preselectedFieldType = useMemo(() => {
+ if (selectedFieldFormId.length === 0) {
+ return null;
+ }
+
+ const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId));
+
+ if (fields.length === 0) {
+ return null;
+ }
+
+ const firstType = fields[0].type;
+ const isTypesSame = fields.every((field) => field.type === firstType);
+
+ return isTypesSame ? firstType : null;
+ }, [editorFields.localFields, selectedFieldFormId]);
+
/**
* Decide the preselected recipient in the command input.
*
@@ -656,6 +798,7 @@ const FieldActionButtons = ({
+
+
);
};
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
index a5ec4ed16..945d3c308 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
@@ -1,3 +1,4 @@
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -17,6 +18,7 @@ import {
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
+import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
-import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
+import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
- const { currentEnvelopeItem } = useCurrentEnvelopeRender();
+ const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
@@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
+ /**
+ * Debounce the fields used for overlap detection so we don't recompute on every
+ * small drag/resize movement, which is expensive on large field counts and can
+ * bog down lower-end devices.
+ */
+ const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
+
+ /**
+ * Fields that significantly overlap each other. Overlapping fields render poorly in
+ * the editor and can behave unexpectedly during signing, so we warn the author here.
+ */
+ const overlappingFieldPairs = useMemo(
+ () =>
+ getOverlappingFieldPairs(
+ debouncedLocalFields.map((field) => ({
+ id: field.formId,
+ envelopeItemId: field.envelopeItemId,
+ page: field.page,
+ positionX: field.positionX,
+ positionY: field.positionY,
+ width: field.width,
+ height: field.height,
+ })),
+ ),
+ [debouncedLocalFields],
+ );
+
+ const handleReviewOverlappingField = () => {
+ const firstPair = overlappingFieldPairs[0];
+
+ if (!firstPair) {
+ return;
+ }
+
+ const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
+
+ if (!targetField) {
+ return;
+ }
+
+ if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
+ setCurrentEnvelopeItem(targetField.envelopeItemId);
+ }
+
+ editorFields.setSelectedField(targetField.formId);
+ };
+
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
if (!selectedField) {
return;
@@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => {
)}
+ {overlappingFieldPairs.length > 0 && (
+
+
+
+
+
+
+ Overlapping fields detected
+
+
+
+ Some fields are placed on top of each other. This may complicate the signing process or cause
+ fields to not work as expected.
+
+
+
+
+
+ )}
+
{currentEnvelopeItem !== null ? (
Rejected
))
+ .with(DocumentStatus.CANCELLED, () => (
+
+ Cancelled
+
+ ))
.exhaustive()}
{autosaveError && (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
index dc7a01d4d..3c2430a37 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
@@ -16,6 +16,7 @@ import {
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -563,6 +564,9 @@ export const EnvelopeEditorRecipientForm = () => {
}
}, [formValues]);
+ const recipientCountLimit = organisation.organisationClaim.recipientCount;
+ const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
+
return (
@@ -627,6 +631,17 @@ export const EnvelopeEditorRecipientForm = () => {
+ {isOverRecipientLimit && (
+
+
+
+ This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need
+ more.
+
+
+
+ )}
+