Compare commits

...

9 Commits

Author SHA1 Message Date
Lucas Smith 7d38e18f93 v2.5.0 2026-01-26 15:59:30 +11:00
Lucas Smith 0a3e0b8727 feat: validate signers have signature fields before distribution (#2411)
API users were inadvertently sending documents without signature fields,
causing confusion for recipients and breaking their signing flows.

- Add getRecipientsWithMissingFields helper in recipients.ts
- Add server-side validation in sendDocument to block distribution
- Fix v1 API to return 400 instead of 500 for validation errors
- Consolidate UI signature field checks to use isSignatureFieldType
- Add E2E tests for both v1 and v2 APIs
2026-01-26 15:22:12 +11:00
github-actions[bot] b538580a1e chore: extract translations (#2380) 2026-01-26 12:21:02 +11:00
Lucas Smith 42d6e1cbbd chore: upgrade libpdf (#2409) 2026-01-26 12:20:33 +11:00
Lucas Smith 67da488f63 chore: upgrade libpdf (#2408) 2026-01-23 21:38:48 +11:00
Lucas Smith fd3ebc08ec chore: upgrade libpdf (#2406) 2026-01-22 12:45:20 +11:00
Catalin Pit a7963b385a docs: add default recipients section (#2400) 2026-01-21 09:45:34 +02:00
Lucas Smith 9035240b4d refactor: replace pdf-sign with libpdf/core for PDF operations (#2403)
Migrate from @documenso/pdf-sign and @cantoo/pdf-lib to @libpdf/core
for all PDF manipulation and signing operations. This includes:

- New signing transports for Google Cloud KMS and local certificates
- Consolidated PDF operations using libpdf API
- Added TSA (timestamp authority) helper for digital signatures
- Removed deprecated flatten and insert utilities
- Updated tests to use new PDF library
2026-01-21 15:16:23 +11:00
Ephraim Duncan ed7a0011c7 fix: sync envelope state after direct link changes (#2257) 2026-01-21 14:43:24 +11:00
79 changed files with 2858 additions and 1974 deletions
@@ -0,0 +1,76 @@
---
date: 2026-01-26
title: Validate Signer Fields On Distribute
---
## Summary
Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior.
## Background
The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed.
However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow.
## Problem
The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution.
## Solution
### 1. Create centralized validation helper
**File**: `packages/lib/utils/recipients.ts`
- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`)
- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields
- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts`
### 2. Add server-side validation
**File**: `packages/lib/server-only/document/send-document.ts`
- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields
- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()`
### 3. Fix v1 API error handling
**File**: `packages/api/v1/implementation.ts`
- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500
- Now returns 400 for validation errors
### 4. Update UI to use shared helper
**Files**:
- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx`
- `packages/ui/primitives/document-flow/add-fields.tsx`
### 5. Consolidate `hasSignatureField` checks
Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`):
- `apps/remix/app/components/general/document-signing/document-signing-form.tsx`
- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx`
- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx`
- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx`
- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx`
### 6. Add E2E tests
**Files**:
- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests
- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests
## Test Coverage
- Distribution fails when signer has no fields
- Distribution fails when signer has only non-signature fields
- Distribution succeeds with SIGNATURE field
- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma)
- Distribution succeeds when VIEWER/CC/APPROVER have no fields
- Distribution fails when one of multiple signers is missing signature field
- Distribution succeeds when all signers have signature fields
+12
View File
@@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER=
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@@ -291,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
@@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request.
Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file:
| Environment Variable | Description |
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. |
| Environment Variable | Description |
| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. |
</Steps>
@@ -5,4 +5,5 @@ export default {
fields: 'Document Fields',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
'default-recipients': 'Default Recipients',
};
@@ -0,0 +1,45 @@
---
title: Default Document Recipients
description: Learn how to set default recipients with various roles for your documents.
---
import { Callout, Steps } from 'nextra/components';
# Default Document Recipients
Documenso allows you to set default recipients for your documents. This is useful when you require specific recipients to be added to every document you send.
You can add default recipients with the same roles as the recipients you can add when sending a document:
- **Signer** - The recipient will be required to sign the document.
- **Approver** - The recipient will be required to approve the document.
- **Viewer** - The recipient will be required to view the document.
- **CC** - The recipient will receive a copy of the document.
You can set default recipients at the organisation or team level.
### Organisation level
To set default recipients at the organisation level, navigate to the organisation settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section and add the recipients you want to be included in every document you send.
![A screenshot of the organisation's default recipients page](/default-recipients/organisation-default-recipients-select-step.webp)
The recipients are added with the "CC" role by default, but you can select a different role for each recipient.
![A screenshot of the organisation's default recipients page when selecting the role of the recipient](/default-recipients/organisation-default-recipients-role-step.webp)
### Team level
Setting the default recipients at the team level follows the same process as setting them at the organisation level.
<Callout type="info">
Setting the default recipients at the team level will override organisation-level defaults.
</Callout>
To set default recipients at the team level, navigate to the team settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section. By default, the team will inherit the default recipients from the organisation. You can override these defaults by adding the recipients you want to be added to every document you send.
![A screenshot of the team's default recipients page](/default-recipients/team-default-recipients.webp)
Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -20,6 +14,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
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';
@@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({
);
const recipientsMissingSignatureFields = useMemo(
() =>
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
[recipientsWithIndex, envelope.fields],
);
@@ -54,6 +54,8 @@ type TemplateDirectLinkDialogProps = {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
@@ -63,6 +65,8 @@ export const TemplateDirectLinkDialog = ({
directLink,
recipients,
trigger,
onCreateSuccess,
onDeleteSuccess,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
@@ -97,6 +101,7 @@ export const TemplateDirectLinkDialog = ({
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: async (data) => {
await revalidate();
await onCreateSuccess?.();
setToken(data.token);
setIsEnabled(data.enabled);
@@ -142,6 +147,7 @@ export const TemplateDirectLinkDialog = ({
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: async () => {
await revalidate();
await onDeleteSuccess?.();
setOpen(false);
setToken(null);
@@ -234,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
</li>
))}
</ul>
@@ -320,13 +326,13 @@ export const TemplateDirectLinkDialog = ({
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
<p className="text-xs text-muted-foreground/70">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className="text-sm text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
@@ -350,7 +356,7 @@ export const TemplateDirectLinkDialog = ({
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Or</Trans>
</p>
)}
@@ -392,7 +398,7 @@ export const TemplateDirectLinkDialog = ({
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
@@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import {
type DocumentMeta,
type EnvelopeItem,
type Field,
FieldType,
type Recipient,
type Signature,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -18,6 +24,7 @@ import {
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import {
@@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -3,12 +3,13 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DragDropContext,
@@ -7,28 +7,23 @@ import {
Droppable,
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isDeepEqual } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -67,26 +62,9 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -145,7 +123,6 @@ export const EnvelopeEditorRecipientForm = () => {
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
@@ -161,42 +138,7 @@ export const EnvelopeEditorRecipientForm = () => {
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
const form = useForm<TEnvelopeRecipientsForm>({
resolver: zodResolver(ZEnvelopeRecipientsForm),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
defaultValues: {
signers:
recipients.length > 0
? sortBy(
recipients.map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})),
[prop('signingOrder'), 'asc'],
[prop('id'), 'asc'],
)
: defaultRecipients,
signingOrder: envelope.documentMeta.signingOrder,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
},
});
const { form } = editorRecipients;
const recipientHasAuthSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
@@ -588,7 +530,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -848,246 +790,205 @@ export const EnvelopeEditorRecipientForm = () => {
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientActionAuthSelect
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -1100,12 +1001,63 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
))}
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
@@ -81,7 +81,7 @@ export default function EnvelopeEditor() {
isAutosaving,
flushAutosave,
relativePath,
editorFields,
syncEnvelope,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
@@ -278,6 +278,8 @@ export default function EnvelopeEditor() {
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
@@ -30,7 +31,7 @@ export default function EnvelopeSignerForm() {
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
return recipientFields.some((field) => isSignatureFieldType(field.type));
}, [recipientFields]);
const isSubmitting = false;
Binary file not shown.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.4.0"
"version": "2.5.0"
}
-2
View File
@@ -67,7 +67,6 @@ export default defineConfig({
'node_modules',
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
'playwright',
'playwright-core',
@@ -98,7 +97,6 @@ export default defineConfig({
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
'nodemailer',
/playwright/,
+60 -50
View File
@@ -42,16 +42,17 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
4. Set up your signing certificate. You have three options:
**Option A: Generate Certificate Inside Container (Recommended)**
Start your containers first, then generate a self-signed certificate:
```bash
# Start containers
docker-compose up -d
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
@@ -63,19 +64,19 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
-passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt
"
# Restart container
docker-compose restart documenso
```
**Option B: Use Existing Certificate**
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
```yaml
volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
```
5. Run the following command to start the containers:
@@ -157,7 +158,6 @@ If you encounter errors related to certificate access, here are common solutions
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
```
### Container Logs
Check application logs for detailed error information:
@@ -202,45 +202,55 @@ The environment variables listed above are a subset of those that are available
Here's a markdown table documenting all the provided environment variables:
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| Variable | Description |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
+569 -550
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.4.0",
"version": "2.5.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -85,8 +85,8 @@
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/pdf-sign": "^0.1.0",
"@documenso/prisma": "*",
"@libpdf/core": "^0.2.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"ai": "^5.0.104",
@@ -103,4 +103,4 @@
"typescript": "5.6.2",
"zod": "^3.25.76"
}
}
}
+1 -6
View File
@@ -1041,12 +1041,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while sending the document for signing',
},
};
return AppError.toRestAPIError(err);
}
}),
@@ -4,7 +4,11 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Document API', () => {
@@ -145,4 +149,293 @@ test.describe('Document API', () => {
ownerDocumentCompleted: false,
});
});
test('sendDocument: should fail when signer has no signature field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient without any fields
await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-1',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should fail when signer has only non-signature fields', async ({
request,
}) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient with only a TEXT field (not signature)
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-2',
envelopeId: document.id,
},
});
// Add a TEXT field (not a signature field)
await prisma.field.create({
data: {
type: FieldType.TEXT,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
});
test('sendDocument: should succeed when signer has signature field', async ({ request }) => {
const { user, team } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
teamId: team.id,
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when signer has FREE_SIGNATURE field', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer recipient
const recipient = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-3',
envelopeId: document.id,
},
});
// Add a FREE_SIGNATURE field
await prisma.field.create({
data: {
type: FieldType.FREE_SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
customText: '',
inserted: false,
recipientId: recipient.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when non-signer roles have no fields', async ({ request }) => {
const { user, team } = await seedUser();
// Create a blank document and get it with envelope items
const blankDocument = await seedBlankDocument(user, team.id);
const document = await prisma.envelope.findUniqueOrThrow({
where: { id: blankDocument.id },
include: { envelopeItems: true },
});
// Add a signer with signature field
const signer = await prisma.recipient.create({
data: {
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
token: 'test-token-4',
envelopeId: document.id,
},
});
await prisma.field.create({
data: {
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
recipientId: signer.id,
envelopeId: document.id,
envelopeItemId: document.envelopeItems[0].id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// Add a viewer without any fields
await prisma.recipient.create({
data: {
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
token: 'test-token-5',
envelopeId: document.id,
},
});
// Add an approver without any fields
await prisma.recipient.create({
data: {
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
token: 'test-token-6',
envelopeId: document.id,
},
});
// Add a CC without any fields
await prisma.recipient.create({
data: {
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
token: 'test-token-7',
envelopeId: document.id,
},
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -444,6 +462,24 @@ test.describe('Template Field Prefill API v1', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -221,7 +221,7 @@ test.describe('Document Access API V1', () => {
);
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
expect(resB.status()).toBe(404);
});
test('should allow authorized access to document send endpoint', async ({ request }) => {
@@ -0,0 +1,403 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Envelope distribute validation', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Test Document',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
const createRecipients = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: recipients,
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
const createFields = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
envelopeItemId: string,
fields: Array<{ recipientId: number; type: FieldType }>,
) => {
const res = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}` },
data: {
envelopeId,
data: fields.map((field, index) => ({
recipientId: field.recipientId,
envelopeItemId,
type: field.type,
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 10,
height: 10,
})),
},
});
expect(res.ok()).toBeTruthy();
return (await res.json()).data;
};
test('should fail to distribute when signer has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
// Create a signer without any fields
await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Try to distribute without adding any fields
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
expect(errorResponse.message).toContain('Signers must have at least one signature field');
});
test('should fail to distribute when signer has non-signature fields only', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add only a TEXT field (not a signature field)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.TEXT },
]);
// Try to distribute
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when signer has SIGNATURE field', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add a SIGNATURE field
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
const response = await distributeRes.json();
expect(response.success).toBe(true);
});
// Note: FREE_SIGNATURE field type is not supported via the v2 API for field creation,
// so we only test with SIGNATURE fields here. The v1 tests cover FREE_SIGNATURE
// using direct Prisma creation.
test('should succeed when VIEWER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a viewer
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'viewer@example.com',
name: 'Test Viewer',
role: RecipientRole.VIEWER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (viewer has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when CC has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and a CC recipient
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'cc@example.com',
name: 'Test CC',
role: RecipientRole.CC,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (CC has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should succeed when APPROVER has no fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create a signer and an approver
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer@example.com',
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'approver@example.com',
name: 'Test Approver',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the signer (approver has no fields)
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
test('should fail when one of multiple signers is missing signature field', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature field only for the first signer
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
]);
// Distribute should fail because second signer has no signature field
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('missing required fields');
});
test('should succeed when all signers have signature fields', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
// Create two signers
const recipients = await createRecipients(request, token, envelope.id, [
{
email: 'signer1@example.com',
name: 'Test Signer 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: 'signer2@example.com',
name: 'Test Signer 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
]);
// Add signature fields for both signers
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
{ recipientId: recipients[1].id, type: FieldType.SIGNATURE },
]);
// Distribute should succeed
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id },
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
});
});
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -441,6 +459,24 @@ test.describe('Template Field Prefill API v2', () => {
},
});
// Add SIGNATURE field (required for distribution)
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: firstEnvelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
// 6. Sign in as the user
await apiSignin({
page,
@@ -195,6 +195,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -207,6 +232,31 @@ test.describe('Document API V2', () => {
test('should allow authorized access to document distribute endpoint', async ({ request }) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can run.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
@@ -3678,6 +3728,26 @@ test.describe('Document API V2', () => {
internalVersion: 2,
});
const [recipient] = doc.recipients;
// add signing field for recipient (fieldMeta required for v2 envelopes)
await prisma.field.create({
data: {
page: 1,
type: FieldType.SIGNATURE,
inserted: false,
customText: '',
positionX: 1,
positionY: 1,
width: 1,
height: 1,
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const payload: TUseEnvelopePayload = {
envelopeId: doc.id,
distributeDocument: true,
@@ -3741,6 +3811,31 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
// Get the recipient created during seeding.
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
envelopeId: doc.id,
},
});
// Create a signature field for the recipient so distribution validation can pass.
await prisma.field.create({
data: {
envelopeId: doc.id,
envelopeItemId: doc.envelopeItems[0].id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 1,
positionY: 1,
width: 1,
height: 1,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', fontSize: 14 },
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { envelopeId: doc.id },
@@ -257,10 +257,12 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Change second recipient role if role selector is available
const roleDropdown = page.getByLabel('Role').nth(1);
let secondRecipientIsApprover = false;
if (await roleDropdown.isVisible()) {
await roleDropdown.click();
await page.getByText('Approver').click();
secondRecipientIsApprover = true;
}
// Step 3: Add different field types for each duplicate
@@ -281,6 +283,13 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
}
// Complete the document
await page.getByRole('button', { name: 'Continue' }).click();
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
@@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const pdfDoc = await PDFDocument.load(completedDocumentData);
const pdfDoc = await PDF.load(new Uint8Array(completedDocumentData));
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(documentData);
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
});
@@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => {
);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
});
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
@@ -39,24 +39,30 @@ const TEST_FORM_VALUES = {
* Returns true if the PDF has form fields, false if they've been flattened.
*/
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const fields = form.getFields();
const form = await pdfDoc.getForm();
return fields.length > 0;
if (!form) {
return false;
}
return form.fieldCount > 0;
}
/**
* Helper to get form field names from a PDF.
*/
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const fields = form.getFields();
const form = await pdfDoc.getForm();
return fields.map((field) => field.getName());
if (!form) {
return [];
}
return form.getFieldNames();
}
/**
@@ -66,17 +72,21 @@ async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm();
const form = await pdfDoc.getForm();
try {
const textField = form.getTextField(fieldName);
return textField.getText() ?? '';
} catch {
if (!form) {
return undefined;
}
const textField = form.getTextField(fieldName);
if (!textField) {
return undefined;
}
return textField.getValue();
}
test.describe.configure({
@@ -50,11 +50,13 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their field
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -0,0 +1,106 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
export const ZEditorRecipientsFormSchema = z.object({
signers: z.array(LocalRecipientSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
form: UseFormReturn<TEditorRecipientsFormSchema>;
resetForm: (options?: ResetFormOptions) => void;
};
export const useEditorRecipients = ({
envelope,
}: EditorRecipientsProps): UseEditorRecipientsResponse => {
const initialId = useId();
const generateDefaultValues = (options?: ResetFormOptions) => {
const { recipients, documentMeta } = options ?? {};
const formRecipients = (recipients || envelope.recipients).map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}));
const signers: TLocalRecipient[] =
formRecipients.length > 0
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
: [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
return {
signers,
signingOrder: documentMeta?.signingOrder ?? envelope.documentMeta.signingOrder,
allowDictateNextSigner:
documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
};
};
const form = useForm<TEditorRecipientsFormSchema>({
defaultValues: generateDefaultValues(),
resolver: zodResolver(ZEditorRecipientsFormSchema),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
});
const resetForm = (options?: ResetFormOptions) => {
form.reset(generateDefaultValues(options));
};
return {
form,
resetForm,
};
};
@@ -15,6 +15,7 @@ import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
@@ -53,6 +54,7 @@ type EnvelopeEditorProviderValue = {
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
@@ -101,6 +103,10 @@ export const EnvelopeEditorProvider = ({
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const editorRecipients = useEditorRecipients({
envelope,
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
setEnvelope({
@@ -291,6 +297,12 @@ export const EnvelopeEditorProvider = ({
if (fetchedEnvelopeData.data) {
setEnvelope(fetchedEnvelopeData.data);
editorRecipients.resetForm({
recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta,
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
}
};
@@ -348,6 +360,7 @@ export const EnvelopeEditorProvider = ({
setRecipientsDebounced,
setRecipientsAsync,
editorFields,
editorRecipients,
autosaveError,
flushAutosave,
isAutosaving,
+9
View File
@@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
export const NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER = () =>
env('NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER') === 'true';
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -30,3 +36,6 @@ export const IS_AI_FEATURES_CONFIGURED = () =>
*/
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
@@ -1,12 +1,5 @@
import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
@@ -18,8 +11,8 @@ import {
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma';
@@ -31,14 +24,9 @@ import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -181,8 +169,8 @@ export const run = async ({
});
}
let certificateDoc: PDFDocument | null = null;
let auditLogDoc: PDFDocument | null = null;
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
@@ -208,7 +196,7 @@ export const run = async ({
? getCertificatePdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () =>
@@ -216,7 +204,7 @@ export const run = async ({
? getAuditLogsPdf({
documentId,
language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer))
}).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload);
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
@@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateDoc: PDFDocument | null;
auditLogDoc: PDFDocument | null;
certificateDoc: PDF | null;
auditLogDoc: PDF | null;
};
/**
@@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDFDocument.load(pdfData);
let pdfDoc = await PDF.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
pdfDoc.flattenAll();
// Upgrade to PDF 1.7 for better compatibility with signing
pdfDoc.upgradeVersion('1.7');
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
if (isRejected) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateDoc) {
const certificatePages = await pdfDoc.copyPages(
await pdfDoc.copyPagesFrom(
certificateDoc,
certificateDoc.getPageIndices(),
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
if (auditLogDoc) {
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
await pdfDoc.copyPagesFrom(
auditLogDoc,
Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
);
}
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
}
}
}
await pdfDoc.reload(await legacy_pdfLibDoc.save());
}
// Handle V2 envelope insertions.
@@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
const pageWidth = page.width;
const pageHeight = page.height;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
const overlayPdf = await PDF.load(overlayBytes);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
// Rotate the page to the orientation that the react-pdf renders on the frontend.
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
// Draw the overlay on the page
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
await flattenForm(pdfDoc);
pdfDoc.flattenAll();
const pdfBytes = await pdfDoc.save();
pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const pdfBytes = await signPdf({ pdf: pdfDoc });
const { name } = path.parse(envelopeItem.title);
@@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
arrayBuffer: async () => Promise.resolve(pdfBytes),
});
return {
@@ -38,7 +38,10 @@ import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import {
getRecipientsWithMissingFields,
isRecipientEmailValidForSending,
} from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -148,30 +151,19 @@ export const sendDocument = async ({
}
});
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.fields,
);
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
if (recipientsWithMissingFields.length > 0) {
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
});
}
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
(recipient) =>
@@ -1,88 +1,72 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { type PDF, rgb } from '@libpdf/core';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
export async function addRejectionStampToPdf(
pdfDoc: PDFDocument,
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
const pages = pdf.getPages();
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
const font = await pdfDoc.embedFont(fontBytes, {
customName: 'Noto',
});
const font = pdf.embedFont(new Uint8Array(fontBytes));
const form = pdfDoc.getForm();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = getPageSize(page);
for (const page of pages) {
const height = page.height;
const width = page.width;
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
const rotationAngle = 45;
// Calculate the center position of the page
const centerX = width / 2;
const centerY = height / 2;
// Position the title text at the center of the page
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize);
// Add padding for the rectangle
const padding = 20;
const rectWidth = widthOfText + padding;
const rectHeight = rejectedTitleFontSize + padding;
const rectX = centerX - rectWidth / 2;
const rectY = centerY - rectHeight / 4;
// Draw the stamp background
page.drawRectangle({
x: rejectedTitleTextX - padding / 2,
y: rejectedTitleTextY - padding / 2,
width: rejectedTitleTextWidth + padding,
height: rejectedTitleTextHeight + padding,
x: rectX,
y: rectY,
width: rectWidth,
height: rectHeight,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4,
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
rejectedTitleTextField.addToPage(page, {
x: rejectedTitleTextX,
y: rejectedTitleTextY,
width: rejectedTitleTextWidth,
height: rejectedTitleTextHeight,
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
backgroundColor: undefined,
borderWidth: 0,
borderColor: undefined,
const textX = centerX - widthOfText / 2;
const textY = centerY;
// Draw the text centered within the rectangle
page.drawText(rejectedTitleText, {
x: textX,
y: textY,
size: rejectedTitleFontSize,
font,
color: rgb(220 / 255, 38 / 255, 38 / 255),
rotate: {
angle: rotationAngle,
origin: 'center',
},
});
}
return pdfDoc;
return pdf;
}
@@ -1,63 +0,0 @@
import { PDFAnnotation, PDFRef } from '@cantoo/pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
export const flattenAnnotations = (document: PDFDocument) => {
const pages = document.getPages();
for (const page of pages) {
const annotations = page.node.Annots()?.asArray() ?? [];
annotations.forEach((annotation) => {
if (!(annotation instanceof PDFRef)) {
return;
}
const actualAnnotation = page.node.context.lookup(annotation);
if (!(actualAnnotation instanceof PDFDict)) {
return;
}
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
const appearance = pdfAnnot.ensureAP();
// Skip annotations without a normal appearance
if (!appearance.has(PDFName.of('N'))) {
return;
}
const normalAppearance = pdfAnnot.getNormalAppearance();
const rectangle = pdfAnnot.getRectangle();
if (!(normalAppearance instanceof PDFRef)) {
// Not sure how to get the reference to the normal appearance yet
// so we should skip this annotation for now
return;
}
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xobj),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
page.node.removeAnnot(annotation);
});
}
};
@@ -1,170 +0,0 @@
import type { PDFField, PDFWidgetAnnotation } from '@cantoo/pdf-lib';
import {
PDFCheckBox,
PDFDict,
type PDFDocument,
PDFName,
PDFNumber,
PDFRadioGroup,
PDFRef,
PDFStream,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
if (catalog instanceof PDFDict) {
catalog.delete(PDFName.of('OCProperties'));
}
};
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
const fontNoto = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
/**
* Ensures that an appearance stream has the required dictionary entries to be
* used as a Form XObject. Some PDFs have appearance streams that are missing
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
*
* Per PDF spec, a Form XObject stream requires:
* - /Subtype /Form (required)
* - /BBox (required, but should already exist for appearance streams)
* - /FormType 1 (optional, defaults to 1)
*/
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
const appearanceStream = document.context.lookup(appearanceRef);
if (!(appearanceStream instanceof PDFStream)) {
return;
}
const dict = appearanceStream.dict;
// Ensure /Subtype /Form is set (required for XObject Form)
if (!dict.has(PDFName.of('Subtype'))) {
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
}
// Ensure /FormType is set (optional, but good practice)
if (!dict.has(PDFName.of('FormType'))) {
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
// Ensure the appearance stream has required XObject Form dictionary entries
normalizeAppearanceStream(document, appearanceRef);
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};
@@ -1,3 +1,4 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
@@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
@@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
i18n,
});
return await mergeFilesIntoPdf(auditLogPages);
return await PDF.merge(auditLogPages, {
includeAnnotations: true,
});
};
const getAuditLogs = async (envelopeId: string) => {
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
@@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages);
return await PDF.merge(certificatePages);
};
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}
@@ -1,10 +1,4 @@
import {
PDFCheckBox,
PDFDocument,
PDFDropdown,
PDFRadioGroup,
PDFTextField,
} from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
export type InsertFormValuesInPdfOptions = {
pdf: Buffer;
@@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = {
};
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
const doc = await PDFDocument.load(pdf);
const doc = await PDF.load(pdf);
const form = doc.getForm();
@@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue
return pdf;
}
for (const [key, value] of Object.entries(formValues)) {
try {
const field = form.getField(key);
const filledForm = Object.entries(formValues).map(([key, value]) => [
key,
typeof value === 'boolean' ? value : value.toString(),
]);
if (!field) {
continue;
}
form.fill(Object.fromEntries(filledForm));
if (typeof value === 'boolean' && field instanceof PDFCheckBox) {
if (value) {
field.check();
} else {
field.uncheck();
}
}
if (field instanceof PDFTextField) {
field.setText(value.toString());
}
if (field instanceof PDFDropdown) {
field.select(value.toString());
}
if (field instanceof PDFRadioGroup) {
field.select(value.toString());
}
} catch (err) {
if (err instanceof Error) {
console.error(`Error setting value for field ${key}: ${err.message}`);
} else {
console.error(`Error setting value for field ${key}`);
}
}
}
return await doc.save().then((buf) => Buffer.from(buf));
return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf));
};
@@ -1,26 +0,0 @@
import { PDFDocument } from '@cantoo/pdf-lib';
export async function insertImageInPDF(
pdfAsBase64: string,
image: string | Uint8Array | ArrayBuffer,
positionX: number,
positionY: number,
page = 0,
): Promise<string> {
const existingPdfBytes = pdfAsBase64;
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const pngImage = await pdfDoc.embedPng(image);
const drawSize = { width: 192, height: 64 };
pdfPage.drawImage(pngImage, {
x: positionX,
y: pdfPage.getHeight() - positionY - drawSize.height,
width: drawSize.width,
height: drawSize.height,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}
@@ -1,54 +0,0 @@
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
positionX: number,
positionY: number,
page = 0,
useHandwritingFont = true,
customFontSize?: number,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH());
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };
// Because pdf-lib use a bottom-left coordinate system, we need to invert the y position
// we then center the text in the middle by adding half the height of the text
// plus the height of the field and divide the result by 2
const invertedYPosition =
pdfPage.getHeight() - positionY - (fieldSize.height + textHeight / 2) / 2;
// We center the text by adding the width of the field, subtracting the width of the text
// and dividing the result by 2
const centeredXPosition = positionX + (fieldSize.width - textWidth) / 2;
pdfPage.drawText(text, {
x: centeredXPosition,
y: invertedYPosition,
size: textSize,
color: rgb(0, 0, 0),
font,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}
@@ -1,13 +1,11 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true;
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
const pdfDoc = await PDF.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
@@ -21,11 +19,13 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
});
}
removeOptionalContentGroups(pdfDoc);
pdfDoc.flattenLayers();
if (shouldFlattenForm) {
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
const form = pdfDoc.getForm();
if (shouldFlattenForm && form) {
form.flatten();
pdfDoc.flattenAnnotations();
}
return Buffer.from(await pdfDoc.save());
@@ -1,26 +0,0 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { PDFSignature, rectangle } from '@cantoo/pdf-lib';
export const normalizeSignatureAppearances = (document: PDFDocument) => {
const form = document.getForm();
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
};
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Bestätigung"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Versuche, das Dokument erneut zu versiegeln, nützlich nach einer Codeä
msgid "Audit Log"
msgstr "Audit-Protokoll"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Protokolle"
@@ -2070,6 +2076,7 @@ msgstr "Markenpräferenzen"
msgid "Branding preferences updated"
msgstr "Markenpräferenzen aktualisiert"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Feld in die Zwischenablage kopiert"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "Standarddatei"
msgid "Default Organisation Role for New Users"
msgstr "Standardorganisation Rolle für neue Benutzer"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Standard-Signatureinstellungen"
@@ -3642,6 +3654,7 @@ msgstr "Externe ID des Dokuments aktualisiert"
msgid "Document found in your account"
msgstr "Dokument in Ihrem Konto gefunden"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "Dokument-ID"
@@ -5223,6 +5236,7 @@ msgstr "Authentifizierungsmethode erben"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Eingeladen am"
msgid "Invoice"
msgstr "Rechnung"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft."
msgstr "Andernfalls wird das Dokument als Entwurf erstellt."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Organisationseinstellungen überschreiben"
@@ -7118,6 +7134,10 @@ msgstr "Empfänger"
msgid "Recipients metrics"
msgstr "Empfängermetriken"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Empfänger können das Dokument nach dem Versand unterschreiben"
@@ -7735,6 +7755,11 @@ msgstr "Optionen auswählen"
msgid "Select passkey"
msgstr "Passkey auswählen"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Dies entfernt alle E-Mails, die mit dieser E-Mail-Domain verbunden sind"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Dies meldet Sie auf allen anderen Geräten ab. Sie müssen sich erneut auf diesen Geräten anmelden, um Ihr Konto weiter zu nutzen."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Zeit"
@@ -10148,6 +10174,7 @@ msgstr "Verwenden Sie Ihre Authenticator-App, um einen Code zu generieren"
msgid "Use your passkey for authentication"
msgstr "Verwenden Sie Ihren Passkey zur Authentifizierung"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Einblicke anzeigen"
msgid "View invites"
msgstr "Einladungen ansehen"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Mehr anzeigen"
@@ -11723,4 +11754,3 @@ msgstr "Ihr Verifizierungscode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31
View File
@@ -1003,6 +1003,7 @@ msgid "Acknowledgment"
msgstr "Acknowledgment"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1943,7 +1944,12 @@ msgstr "Attempts sealing the document again, useful for after a code change has
msgid "Audit Log"
msgstr "Audit Log"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr "Audit Log Details"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Audit Logs"
@@ -2065,6 +2071,7 @@ msgstr "Branding Preferences"
msgid "Branding preferences updated"
msgstr "Branding preferences updated"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2729,6 +2736,7 @@ msgstr "Copied field to clipboard"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3100,6 +3108,10 @@ msgstr "Default file"
msgid "Default Organisation Role for New Users"
msgstr "Default Organisation Role for New Users"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr "Default Recipients"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Default Signature Settings"
@@ -3637,6 +3649,7 @@ msgstr "Document external ID updated"
msgid "Document found in your account"
msgstr "Document found in your account"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "Document ID"
@@ -5218,6 +5231,7 @@ msgstr "Inherit authentication method"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5336,6 +5350,7 @@ msgstr "Invited At"
msgid "Invoice"
msgstr "Invoice"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6435,6 +6450,7 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft."
msgstr "Otherwise, the document will be created as a draft."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Override organisation settings"
@@ -7113,6 +7129,10 @@ msgstr "Recipients"
msgid "Recipients metrics"
msgstr "Recipients metrics"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr "Recipients that will be automatically added to new documents."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Recipients will be able to sign the document once sent"
@@ -7730,6 +7750,11 @@ msgstr "Select Options"
msgid "Select passkey"
msgstr "Select passkey"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr "Select recipients"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9485,6 +9510,7 @@ msgstr "This will remove all emails associated with this email domain"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Time"
@@ -10143,6 +10169,7 @@ msgstr "Use your authenticator app to generate a code"
msgid "Use your passkey for authentication"
msgstr "Use your passkey for authentication"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10362,6 +10389,10 @@ msgstr "View insights"
msgid "View invites"
msgstr "View invites"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr "View JSON"
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "View more"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Reconocimiento"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Intenta sellar el documento de nuevo, útil después de que se haya prod
msgid "Audit Log"
msgstr "Registro de Auditoría"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Registros de Auditoría"
@@ -2070,6 +2076,7 @@ msgstr "Preferencias de marca"
msgid "Branding preferences updated"
msgstr "Preferencias de marca actualizadas"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Campo copiado al portapapeles"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "Archivo por defecto"
msgid "Default Organisation Role for New Users"
msgstr "Rol de organización predeterminado para nuevos usuarios"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Configuraciones de Firma por Defecto"
@@ -3642,6 +3654,7 @@ msgstr "Se actualizó el ID externo del documento"
msgid "Document found in your account"
msgstr "Documento encontrado en tu cuenta"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "ID del documento"
@@ -5223,6 +5236,7 @@ msgstr "Heredar método de autenticación"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Invitado el"
msgid "Invoice"
msgstr "Factura"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft."
msgstr "De lo contrario, el documento se creará como un borrador."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Anular la configuración de la organización"
@@ -7118,6 +7134,10 @@ msgstr "Destinatarios"
msgid "Recipients metrics"
msgstr "Métricas de destinatarios"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Los destinatarios podrán firmar el documento una vez enviado"
@@ -7735,6 +7755,11 @@ msgstr "Seleccionar opciones"
msgid "Select passkey"
msgstr "Seleccionar clave de acceso"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Esto eliminará todos los correos electrónicos asociados con este domin
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Esto cerrará la sesión en todos los demás dispositivos. Necesitarás iniciar sesión nuevamente en esos dispositivos para continuar usando tu cuenta."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Hora"
@@ -10148,6 +10174,7 @@ msgstr "Use su aplicación de autenticación para generar un código"
msgid "Use your passkey for authentication"
msgstr "Utilice su clave de acceso para la autenticación"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Ver insights"
msgid "View invites"
msgstr "Ver invitaciones"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Ver más"
@@ -11723,4 +11754,3 @@ msgstr "Su código de verificación:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "su-dominio.com otro-dominio.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Reconnaissance"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Essaye de sceller le document à nouveau, utile après qu'un changement
msgid "Audit Log"
msgstr "Journal d'audit"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Journaux de vérification"
@@ -2070,6 +2076,7 @@ msgstr "Préférences de branding"
msgid "Branding preferences updated"
msgstr "Préférences de branding mises à jour"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Champ copié dans le presse-papiers"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "Fichier par défaut"
msgid "Default Organisation Role for New Users"
msgstr "Rôle par défaut de l'organisation pour les nouveaux utilisateurs"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Paramètres de Signature par Défaut"
@@ -3642,6 +3654,7 @@ msgstr "ID externe du document mis à jour"
msgid "Document found in your account"
msgstr "Document trouvé dans votre compte"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "ID du document"
@@ -5223,6 +5236,7 @@ msgstr "Hériter de la méthode d'authentification"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Invité à"
msgid "Invoice"
msgstr "Facture"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft."
msgstr "Sinon, le document sera créé sous forme de brouillon."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Ignorer les paramètres de l'organisation"
@@ -7118,6 +7134,10 @@ msgstr "Destinataires"
msgid "Recipients metrics"
msgstr "Métriques des destinataires"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Les destinataires pourront signer le document une fois envoyé"
@@ -7735,6 +7755,11 @@ msgstr "Sélectionner des options"
msgid "Select passkey"
msgstr "Sélectionner la clé d'authentification"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Cela supprimera tous les e-mails associés à ce domaine de messagerie"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Cela entraînera votre déconnexion de tous les autres appareils. Vous devrez vous reconnecter sur ces appareils pour continuer à utiliser votre compte."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Temps"
@@ -10148,6 +10174,7 @@ msgstr "Utilisez votre application d'authentification pour générer un code"
msgid "Use your passkey for authentication"
msgstr "Utilisez votre clé d'accès pour l'authentification"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Afficher les statistiques"
msgid "View invites"
msgstr "Voir les invitations"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Voir plus"
@@ -11723,4 +11754,3 @@ msgstr "Votre code de vérification :"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Riconoscimento"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Tenta nuovamente di sigillare il documento, utile dopo una modifica al c
msgid "Audit Log"
msgstr "Registro di controllo"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Registri di Audit"
@@ -2070,6 +2076,7 @@ msgstr "Preferenze per il branding"
msgid "Branding preferences updated"
msgstr "Preferenze di branding aggiornate"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Campo copiato negli appunti"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "File predefinito"
msgid "Default Organisation Role for New Users"
msgstr "Ruolo dell'organizzazione predefinito per nuovi utenti"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Impostazioni predefinite della firma"
@@ -3642,6 +3654,7 @@ msgstr "ID esterno del documento aggiornato"
msgid "Document found in your account"
msgstr "Documento trovato nel tuo account"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "ID del documento"
@@ -5223,6 +5236,7 @@ msgstr "Ereditare metodo di autenticazione"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Invitato il"
msgid "Invoice"
msgstr "Fattura"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Originale"
msgid "Otherwise, the document will be created as a draft."
msgstr "Altrimenti, il documento sarà creato come bozza."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Sovrascrivi impostazioni organizzazione"
@@ -7118,6 +7134,10 @@ msgstr "Destinatari"
msgid "Recipients metrics"
msgstr "Metriche dei destinatari"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "I destinatari potranno firmare il documento una volta inviato"
@@ -7735,6 +7755,11 @@ msgstr "Seleziona opzioni"
msgid "Select passkey"
msgstr "Seleziona una chiave di accesso"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Questo rimuoverà tutte le email associate a questo dominio email"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Questa azione ti disconnetterà da tutti gli altri dispositivi. Dovrai accedere nuovamente su quei dispositivi per continuare a usare il tuo account."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Ora"
@@ -10148,6 +10174,7 @@ msgstr "Usa la tua app di autenticazione per generare un codice"
msgid "Use your passkey for authentication"
msgstr "Usa la tua chiave di accesso per l'autenticazione"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Visualizza approfondimenti"
msgid "View invites"
msgstr "Visualizza inviti"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Vedi di più"
@@ -11723,4 +11754,3 @@ msgstr "Il tuo codice di verifica:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "tuo-dominio.com altro-dominio.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "確認"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "文書の再封止を試行します。コード変更後に文書の不
msgid "Audit Log"
msgstr "監査ログ"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "監査ログ"
@@ -2070,6 +2076,7 @@ msgstr "ブランディング設定"
msgid "Branding preferences updated"
msgstr "ブランディング設定を更新しました"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "フィールドをクリップボードにコピーしました"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "デフォルトのファイル"
msgid "Default Organisation Role for New Users"
msgstr "新規ユーザーの既定の組織ロール"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "既定の署名設定"
@@ -3642,6 +3654,7 @@ msgstr "ドキュメントの外部 ID が更新されました"
msgid "Document found in your account"
msgstr "あなたのアカウントでドキュメントが見つかりました"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "文書 ID"
@@ -5223,6 +5236,7 @@ msgstr "認証方法を継承"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "招待日時"
msgid "Invoice"
msgstr "請求書"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "オリジナル"
msgid "Otherwise, the document will be created as a draft."
msgstr "チェックを入れない場合、文書は下書きとして作成されます。"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "組織設定を上書き"
@@ -7118,6 +7134,10 @@ msgstr "受信者"
msgid "Recipients metrics"
msgstr "受信者は文書のコピーを保持したままです"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "送信後、受信者は文書に署名できるようになります"
@@ -7735,6 +7755,11 @@ msgstr "オプションを選択"
msgid "Select passkey"
msgstr "パスキーを選択"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "この操作により、このメールドメインに関連付けられ
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "これにより、他のすべてのデバイスからサインアウトされます。アカウントを引き続き使用するには、それらのデバイスで再度サインインする必要があります。"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "時刻"
@@ -10148,6 +10174,7 @@ msgstr "認証アプリを使用してコードを生成してください"
msgid "Use your passkey for authentication"
msgstr "パスキーで認証する"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "インサイトを表示"
msgid "View invites"
msgstr "招待を表示"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "さらに表示"
@@ -11723,4 +11754,3 @@ msgstr "認証コード:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "확인"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "코드 변경으로 잘못된 문서를 수정한 후, 문서를 다시
msgid "Audit Log"
msgstr "감사 로그"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "감사 로그"
@@ -2070,6 +2076,7 @@ msgstr "브랜딩 환경설정"
msgid "Branding preferences updated"
msgstr "브랜딩 환경설정이 업데이트되었습니다"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "필드를 클립보드에 복사했습니다."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "기본 파일"
msgid "Default Organisation Role for New Users"
msgstr "새 사용자 기본 조직 역할"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "기본 서명 설정"
@@ -3642,6 +3654,7 @@ msgstr "문서 외부 ID가 업데이트되었습니다."
msgid "Document found in your account"
msgstr "귀하의 계정에서 문서를 찾았습니다."
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "문서 ID"
@@ -5223,6 +5236,7 @@ msgstr "인증 방식 상속"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "초대 일시"
msgid "Invoice"
msgstr "청구서"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "원본"
msgid "Otherwise, the document will be created as a draft."
msgstr "그렇지 않으면 문서는 초안으로 생성됩니다."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "조직 설정 재정의"
@@ -7118,6 +7134,10 @@ msgstr "수신자"
msgid "Recipients metrics"
msgstr "수신자 지표"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "수신자는 문서가 전송된 후 서명할 수 있습니다"
@@ -7735,6 +7755,11 @@ msgstr "옵션 선택"
msgid "Select passkey"
msgstr "패스키 선택"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "이 작업은 이 이메일 도메인과 연결된 모든 이메일을
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "모든 다른 기기에서 로그아웃됩니다. 해당 기기에서 계정을 계속 사용하려면 다시 로그인해야 합니다."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "시간"
@@ -10148,6 +10174,7 @@ msgstr "인증 앱을 사용해 코드를 생성하세요"
msgid "Use your passkey for authentication"
msgstr "패스키로 인증하세요."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "인사이트 보기"
msgid "View invites"
msgstr "초대 보기"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "더 보기"
@@ -11723,4 +11754,3 @@ msgstr "인증 코드:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Kennisgeving"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Probeert het document opnieuw te verzegelen, handig nadat een codewijzig
msgid "Audit Log"
msgstr "Auditlog"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Auditlogs"
@@ -2070,6 +2076,7 @@ msgstr "Brandingvoorkeuren"
msgid "Branding preferences updated"
msgstr "Brandingvoorkeuren bijgewerkt"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Veld naar klembord gekopieerd"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "Standaardbestand"
msgid "Default Organisation Role for New Users"
msgstr "Standaard organisatierol voor nieuwe gebruikers"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Standaardinstellingen voor handtekeningen"
@@ -3642,6 +3654,7 @@ msgstr "Externe document-ID bijgewerkt"
msgid "Document found in your account"
msgstr "Document gevonden in je account"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "DocumentID"
@@ -5223,6 +5236,7 @@ msgstr "Authenticatiemethode overnemen"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Uitgenodigd op"
msgid "Invoice"
msgstr "Factuur"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Origineel"
msgid "Otherwise, the document will be created as a draft."
msgstr "Anders wordt het document als concept aangemaakt."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Organisatie-instellingen overschrijven"
@@ -7118,6 +7134,10 @@ msgstr "Ontvangers"
msgid "Recipients metrics"
msgstr "Ontvangerstatistieken"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Ontvangers kunnen het document ondertekenen zodra het is verzonden"
@@ -7735,6 +7755,11 @@ msgstr "Selecteer opties"
msgid "Select passkey"
msgstr "Passkey selecteren"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Hiermee worden alle e-mails die aan dit e-maildomein zijn gekoppeld verw
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Hiermee wordt je op alle andere apparaten uitgelogd. Je moet op die apparaten opnieuw inloggen om je account verder te gebruiken."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Tijd"
@@ -10148,6 +10174,7 @@ msgstr "Gebruik uw authenticatie-app om een code te genereren"
msgid "Use your passkey for authentication"
msgstr "Gebruik je passkey voor authenticatie"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Inzichten bekijken"
msgid "View invites"
msgstr "Uitnodigingen bekijken"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Meer bekijken"
@@ -11723,4 +11754,3 @@ msgstr "Uw verificatiecode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "Potwierdzenie"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "Ponowna próba zapieczętowania dokumentu jest przydatna po zmianie kodu
msgid "Audit Log"
msgstr "Dziennik logów"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Dziennik logów"
@@ -2070,6 +2076,7 @@ msgstr "Ustawienia brandingu"
msgid "Branding preferences updated"
msgstr "Ustawienia brandingu zostały zaktualizowane"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "Pole zostało skopiowane do schowka"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "Domyślny plik"
msgid "Default Organisation Role for New Users"
msgstr "Domyślna rola w organizacji dla nowych użytkowników"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Domyślne rodzaje dozwolonych podpisów"
@@ -3642,6 +3654,7 @@ msgstr "Zaktualizowano identyfikator zewnętrzny dokumentu"
msgid "Document found in your account"
msgstr "Znaleziono dokument na koncie"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "Identyfikator dokumentu"
@@ -5223,6 +5236,7 @@ msgstr "Odziedzicz metodę uwierzytelniania"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "Data zaproszenia"
msgid "Invoice"
msgstr "Faktura"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "Oryginalny"
msgid "Otherwise, the document will be created as a draft."
msgstr "W przeciwnym razie dokument zostanie utworzony jako wersja robocza."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Nadpisz ustawienia organizacji"
@@ -7118,6 +7134,10 @@ msgstr "Odbiorcy"
msgid "Recipients metrics"
msgstr "Metryki odbiorców"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Odbiorcy będą mogli podpisać dokument po jego wysłaniu"
@@ -7735,6 +7755,11 @@ msgstr "Wybierz opcje"
msgid "Select passkey"
msgstr "Wybierz klucz dostępu"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "Spowoduje to usunięcie wszystkich adresów e-mail powiązanych z domen
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Spowoduje to wylogowanie ze wszystkich innych urządzeń. Konieczne będzie ponownie zalogowanie się na tych urządzeniach."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Czas"
@@ -10148,6 +10174,7 @@ msgstr "Użyj aplikacji uwierzytelniającej, aby wygenerować kod"
msgid "Use your passkey for authentication"
msgstr "Użyj klucza dostępu do uwierzytelniania"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "Wyświetl statystyki"
msgid "View invites"
msgstr "Wyświetl zaproszenia"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Wyświetl więcej"
@@ -11723,4 +11754,3 @@ msgstr "Twój kod weryfikacyjny:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
+31
View File
@@ -1003,6 +1003,7 @@ msgid "Acknowledgment"
msgstr "Reconhecimento"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1943,7 +1944,12 @@ msgstr "Tenta selar o documento novamente, útil após uma alteração de códig
msgid "Audit Log"
msgstr "Log de Auditoria"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "Logs de Auditoria"
@@ -2065,6 +2071,7 @@ msgstr "Preferências da Marca"
msgid "Branding preferences updated"
msgstr "Preferências da marca atualizadas"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2729,6 +2736,7 @@ msgstr "Campo copiado para a área de transferência"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3100,6 +3108,10 @@ msgstr "Arquivo padrão"
msgid "Default Organisation Role for New Users"
msgstr "Função Padrão da Organização para Novos Usuários"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "Configurações de Assinatura Padrão"
@@ -3637,6 +3649,7 @@ msgstr "ID externo do documento atualizado"
msgid "Document found in your account"
msgstr "Documento encontrado em sua conta"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "ID do Documento"
@@ -5218,6 +5231,7 @@ msgstr "Herdar método de autenticação"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5336,6 +5350,7 @@ msgstr "Convidado em"
msgid "Invoice"
msgstr "Fatura"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6435,6 +6450,7 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft."
msgstr "Caso contrário, o documento será criado como um rascunho."
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "Substituir configurações da organização"
@@ -7113,6 +7129,10 @@ msgstr "Destinatários"
msgid "Recipients metrics"
msgstr "Métricas de destinatários"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "Os destinatários poderão assinar o documento assim que enviado"
@@ -7730,6 +7750,11 @@ msgstr ""
msgid "Select passkey"
msgstr "Selecionar passkey"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9485,6 +9510,7 @@ msgstr "Isso removerá todos os e-mails associados a este domínio de e-mail"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "Isso desconectará você de todos os outros dispositivos. Você precisará entrar novamente nesses dispositivos para continuar usando sua conta."
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Hora"
@@ -10143,6 +10169,7 @@ msgstr "Use seu aplicativo autenticador para gerar um código"
msgid "Use your passkey for authentication"
msgstr "Use sua passkey para autenticação"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10362,6 +10389,10 @@ msgstr "Visualizar insights"
msgid "View invites"
msgstr "Visualizar convites"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "Ver mais"
+31 -1
View File
@@ -1008,6 +1008,7 @@ msgid "Acknowledgment"
msgstr "确认"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
@@ -1948,7 +1949,12 @@ msgstr "将再次尝试对文档进行封存,这在代码变更后用于修复
msgid "Audit Log"
msgstr "审计日志"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "Audit Log Details"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
msgid "Audit Logs"
msgstr "审计日志"
@@ -2070,6 +2076,7 @@ msgstr "品牌偏好设置"
msgid "Branding preferences updated"
msgstr "品牌偏好设置已更新"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
msgid "Browser"
@@ -2734,6 +2741,7 @@ msgstr "字段已复制到剪贴板"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -3105,6 +3113,10 @@ msgstr "默认文件"
msgid "Default Organisation Role for New Users"
msgstr "新用户的默认组织角色"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Recipients"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Default Signature Settings"
msgstr "默认签名设置"
@@ -3642,6 +3654,7 @@ msgstr "文档外部 ID 已更新"
msgid "Document found in your account"
msgstr "在您的账户中找到了该文档"
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx
msgid "Document ID"
msgstr "文档 ID"
@@ -5223,6 +5236,7 @@ msgstr "继承认证方式"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Inherit from organisation"
@@ -5341,6 +5355,7 @@ msgstr "邀请时间"
msgid "Invoice"
msgstr "发票"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
@@ -6440,6 +6455,7 @@ msgstr "原始"
msgid "Otherwise, the document will be created as a draft."
msgstr "否则将把文档创建为草稿。"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings"
msgstr "覆盖组织设置"
@@ -7118,6 +7134,10 @@ msgstr "收件人"
msgid "Recipients metrics"
msgstr "收件人仍将保留其文档副本"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Recipients that will be automatically added to new documents."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Recipients will be able to sign the document once sent"
msgstr "发送后,收件人将能够签署此文档"
@@ -7735,6 +7755,11 @@ msgstr "选择选项"
msgid "Select passkey"
msgstr "选择通行密钥"
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
#: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx
msgid "Select recipients"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
@@ -9490,6 +9515,7 @@ msgstr "这将移除此邮箱域名下的所有邮箱"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "这将使您在所有其他设备上退出登录。您需要在那些设备上重新登录才能继续使用账户。"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "时间"
@@ -10148,6 +10174,7 @@ msgstr "使用您的验证器应用生成验证码"
msgid "Use your passkey for authentication"
msgstr "使用您的通行密钥进行认证"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
@@ -10367,6 +10394,10 @@ msgstr "查看洞察"
msgid "View invites"
msgstr "查看邀请"
#: apps/remix/app/components/tables/admin-document-logs-table.tsx
msgid "View JSON"
msgstr ""
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
msgid "View more"
msgstr "查看更多"
@@ -11723,4 +11754,3 @@ msgstr "您的验证码:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com"
@@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
@@ -25,7 +25,7 @@ export const putPdfFileServerSide = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
const pdf = await PDF.load(new Uint8Array(arrayBuffer)).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
+10 -4
View File
@@ -6,14 +6,20 @@ declare global {
}
}
type EnvironmentVariable = keyof NodeJS.ProcessEnv;
// eslint-disable-next-line @typescript-eslint/ban-types
type EnvKey = keyof NodeJS.ProcessEnv | (string & {});
type EnvValue<K extends EnvKey> = K extends keyof NodeJS.ProcessEnv
? NodeJS.ProcessEnv[K]
: string | undefined;
export const env = (variable: EnvironmentVariable | (string & object)): string | undefined => {
export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
if (typeof window !== 'undefined' && typeof window.__ENV__ === 'object') {
return window.__ENV__[variable];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return window.__ENV__[variable as string] as EnvValue<K>;
}
return typeof process !== 'undefined' ? process?.env?.[variable] : undefined;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
};
export const createPublicEnv = () =>
+31
View File
@@ -2,9 +2,40 @@ import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { extractLegacyIds } from '../universal/id';
/**
* Roles that require fields to be assigned before a document can be distributed.
*
* Currently only SIGNER requires a signature field.
*/
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
/**
* Returns recipients who are missing required fields for their role.
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
recipients: T[],
fields: Pick<Field, 'type' | 'recipientId'>[],
): T[] => {
return recipients.filter((recipient) => {
if (recipient.role === RecipientRole.SIGNER) {
const hasSignatureField = fields.some(
(field) => field.recipientId === recipient.id && isSignatureFieldType(field.type),
);
return !hasSignatureField;
}
return false;
});
};
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
/**
-3
View File
@@ -1,3 +0,0 @@
// We use stars as a placeholder since it's easy to find and replace,
// the length of the placeholder is to support larger pdf files
export const BYTE_RANGE_PLACEHOLDER = '**********';
@@ -1,96 +0,0 @@
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
rectangle,
} from '@cantoo/pdf-lib';
import { BYTE_RANGE_PLACEHOLDER } from '../constants/byte-range';
export type AddSigningPlaceholderOptions = {
pdf: Buffer;
};
export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => {
const doc = await PDFDocument.load(pdf);
const [firstPage] = doc.getPages();
const byteRange = PDFArray.withContext(doc.context);
byteRange.push(PDFNumber.of(0));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
const signature = doc.context.register(
doc.context.obj({
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'adbe.pkcs7.detached',
ByteRange: byteRange,
Contents: PDFHexString.fromText(' '.repeat(8192)),
Reason: PDFString.of('Signed by Documenso'),
M: PDFString.fromDate(new Date()),
}),
);
const widget = doc.context.register(
doc.context.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Sig',
Rect: [0, 0, 0, 0],
V: signature,
T: PDFString.of('Signature1'),
F: 4,
P: firstPage.ref,
AP: doc.context.obj({
N: doc.context.register(doc.context.formXObject([rectangle(0, 0, 0, 0)])),
}),
}),
);
let widgets: PDFArray;
try {
widgets = firstPage.node.lookup(PDFName.of('Annots'), PDFArray);
} catch {
widgets = PDFArray.withContext(doc.context);
firstPage.node.set(PDFName.of('Annots'), widgets);
}
widgets.push(widget);
let arcoForm: PDFDict;
try {
arcoForm = doc.catalog.lookup(PDFName.of('AcroForm'), PDFDict);
} catch {
arcoForm = doc.context.obj({
Fields: PDFArray.withContext(doc.context),
});
doc.catalog.set(PDFName.of('AcroForm'), arcoForm);
}
let fields: PDFArray;
try {
fields = arcoForm.lookup(PDFName.of('Fields'), PDFArray);
} catch {
fields = PDFArray.withContext(doc.context);
arcoForm.set(PDFName.of('Fields'), fields);
}
fields.push(widget);
arcoForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
return Buffer.from(await doc.save({ useObjectStreams: false }));
};
+33
View File
@@ -0,0 +1,33 @@
import { HttpTimestampAuthority } from '@libpdf/core';
import { once } from 'remeda';
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
const setupTimestampAuthorities = once(() => {
const timestampAuthority = NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY();
if (!timestampAuthority) {
return null;
}
const timestampAuthorities = timestampAuthority
.trim()
.split(',')
.filter(Boolean)
.map((url) => {
return new HttpTimestampAuthority(url);
});
return timestampAuthorities;
});
export const getTimestampAuthority = () => {
const authorities = setupTimestampAuthorities();
if (!authorities) {
return null;
}
// Pick a random authority
return authorities[Math.floor(Math.random() * authorities.length)];
};
@@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest';
import { updateSigningPlaceholder } from './update-signing-placeholder';
describe('updateSigningPlaceholder', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
it('should not throw an error', () => {
expect(() => updateSigningPlaceholder({ pdf })).not.toThrowError();
});
it('should not modify the original PDF', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).not.toEqual(pdf);
});
it('should return a PDF with the same length as the original', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).toHaveLength(pdf.length);
});
it('should update the byte range and return it', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 184, 241, 92]);
});
it('should only update the last signature in the PDF', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
21 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 512, 569, 92]);
});
});
@@ -1,39 +0,0 @@
export type UpdateSigningPlaceholderOptions = {
pdf: Buffer;
};
export const updateSigningPlaceholder = ({ pdf }: UpdateSigningPlaceholderOptions) => {
const length = pdf.length;
const byteRangePos = pdf.lastIndexOf('/ByteRange');
const byteRangeStart = pdf.indexOf('[', byteRangePos);
const byteRangeEnd = pdf.indexOf(']', byteRangePos);
const byteRangeSlice = pdf.subarray(byteRangeStart, byteRangeEnd + 1);
const signaturePos = pdf.indexOf('/Contents', byteRangeEnd);
const signatureStart = pdf.indexOf('<', signaturePos);
const signatureEnd = pdf.indexOf('>', signaturePos);
const signatureSlice = pdf.subarray(signatureStart, signatureEnd + 1);
const byteRange = [0, 0, 0, 0];
byteRange[1] = signatureStart;
byteRange[2] = byteRange[1] + signatureSlice.length;
byteRange[3] = length - byteRange[2];
const newByteRange = `[${byteRange.join(' ')}]`.padEnd(byteRangeSlice.length, ' ');
const updatedPdf = Buffer.concat([
new Uint8Array(pdf.subarray(0, byteRangeStart)),
new Uint8Array(Buffer.from(newByteRange)),
new Uint8Array(pdf.subarray(byteRangeEnd + 1)),
]);
if (updatedPdf.length !== length) {
throw new Error('Updated PDF length does not match original length');
}
return { pdf: updatedPdf, byteRange };
};
+45 -7
View File
@@ -1,21 +1,59 @@
import type { Signer } from '@libpdf/core';
import type { PDF } from '@libpdf/core';
import { match } from 'ts-pattern';
import {
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER,
NEXT_PUBLIC_SIGNING_CONTACT_INFO,
NEXT_PUBLIC_WEBAPP_URL,
} from '@documenso/lib/constants/app';
import { env } from '@documenso/lib/utils/env';
import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm';
import { signWithLocalCert } from './transports/local-cert';
import { getTimestampAuthority } from './helpers/tsa';
import { createGoogleCloudSigner } from './transports/google-cloud';
import { createLocalSigner } from './transports/local';
export type SignOptions = {
pdf: Buffer;
pdf: PDF;
};
export const signPdf = async ({ pdf }: SignOptions) => {
let signer: Signer | null = null;
const getSigner = async () => {
if (signer) {
return signer;
}
const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
return await match(transport)
.with('local', async () => signWithLocalCert({ pdf }))
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf }))
// eslint-disable-next-line require-atomic-updates
signer = await match(transport)
.with('local', async () => await createLocalSigner())
.with('gcloud-hsm', async () => await createGoogleCloudSigner())
.otherwise(() => {
throw new Error(`Unsupported signing transport: ${transport}`);
});
return signer;
};
export const signPdf = async ({ pdf }: SignOptions) => {
const signer = await getSigner();
const tsa = getTimestampAuthority();
const { bytes } = await pdf.sign({
signer,
reason: 'Signed by Documenso',
location: NEXT_PUBLIC_WEBAPP_URL(),
contactInfo: NEXT_PUBLIC_SIGNING_CONTACT_INFO(),
subFilter: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER()
? 'adbe.pkcs7.detached'
: 'ETSI.CAdES.detached',
timestampAuthority: tsa ?? undefined,
longTermValidation: !!tsa,
archivalTimestamp: !!tsa,
});
return bytes;
};
+3 -3
View File
@@ -12,11 +12,11 @@
"test": "vitest"
},
"dependencies": {
"@documenso/pdf-sign": "^0.1.0",
"@documenso/tsconfig": "*",
"@google-cloud/kms": "^5.2.1",
"@google-cloud/secret-manager": "^6.1.1",
"ts-pattern": "^5.9.0"
},
"devDependencies": {
"vitest": "^3.2.4"
"@documenso/tsconfig": "*"
}
}
@@ -1,79 +0,0 @@
import fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
import { signWithGCloud } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithGoogleCloudHSMOptions = {
pdf: Buffer;
};
export const signWithGoogleCloudHSM = async ({ pdf }: SignWithGoogleCloudHSMOptions) => {
const keyPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH');
if (!keyPath) {
throw new Error('No certificate path provided for Google Cloud HSM signing');
}
const googleApplicationCredentials = env('GOOGLE_APPLICATION_CREDENTIALS');
const googleApplicationCredentialsContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS',
);
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (googleApplicationCredentials && googleApplicationCredentialsContents) {
if (!fs.existsSync(googleApplicationCredentials)) {
const contents = new Uint8Array(Buffer.from(googleApplicationCredentialsContents, 'base64'));
fs.writeFileSync(googleApplicationCredentials, contents);
}
}
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
const googleCloudHsmPublicCrtFileContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS',
);
if (googleCloudHsmPublicCrtFileContents) {
cert = Buffer.from(googleCloudHsmPublicCrtFileContents, 'base64');
}
if (!cert) {
cert = Buffer.from(
fs.readFileSync(
env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH') || './example/cert.crt',
),
);
}
const signature = signWithGCloud({
keyPath,
cert,
content: pdfWithoutSignature,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`)),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
return signedPdf;
};
@@ -0,0 +1,85 @@
import { GoogleKmsSigner, parsePem } from '@libpdf/core';
import fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
const loadCertificates = async (): Promise<Uint8Array[]> => {
// Try chain file first (takes precedence)
const chainContents = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS');
const chainFilePath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH');
if (chainContents) {
return parsePem(Buffer.from(chainContents, 'base64').toString('utf-8')).map(
(block) => block.der,
);
}
if (chainFilePath) {
return parsePem(fs.readFileSync(chainFilePath).toString('utf-8')).map((block) => block.der);
}
// Fall back to single certificate (existing behavior)
const certContents = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS');
const certFilePath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH');
if (certContents) {
return parsePem(Buffer.from(certContents, 'base64').toString('utf-8')).map(
(block) => block.der,
);
}
if (certFilePath) {
return parsePem(fs.readFileSync(certFilePath).toString('utf-8')).map((block) => block.der);
}
// Would use: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH
const certPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH');
if (certPath) {
const { cert, chain } = await GoogleKmsSigner.getCertificateFromSecretManager(certPath);
if (chain) {
return [cert, ...chain];
}
return [cert];
}
throw new Error('No certificate found for Google Cloud HSM signing');
};
export const createGoogleCloudSigner = async () => {
const keyPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH');
if (!keyPath) {
throw new Error('No key path provided for Google Cloud HSM signing');
}
const googleAuthCredentials = env('GOOGLE_APPLICATION_CREDENTIALS');
const googleAuthCredentialContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS',
);
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (googleAuthCredentials && googleAuthCredentialContents) {
if (!fs.existsSync(googleAuthCredentials)) {
const contents = new Uint8Array(Buffer.from(googleAuthCredentialContents, 'base64'));
fs.writeFileSync(googleAuthCredentials, contents);
}
}
const certs = await loadCertificates();
if (certs.length === 0) {
throw new Error('No valid certificates found');
}
return GoogleKmsSigner.create({
keyVersionName: keyPath,
certificate: certs[0],
certificateChain: certs.length > 1 ? certs.slice(1) : undefined,
buildChain: true,
});
};
-80
View File
@@ -1,80 +0,0 @@
import * as fs from 'node:fs';
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
import { env } from '@documenso/lib/utils/env';
import { signWithP12 } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithLocalCertOptions = {
pdf: Buffer;
};
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
const signatureLength = byteRange[2] - byteRange[1];
const certStatus = getCertificateStatus();
if (!certStatus.isAvailable) {
console.error('Certificate error: Certificate not available for document signing');
throw new Error('Document signing failed: Certificate not available');
}
let cert: Buffer | null = null;
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
if (localFileContents) {
try {
cert = Buffer.from(localFileContents, 'base64');
} catch {
throw new Error('Failed to decode certificate contents');
}
}
if (!cert) {
let certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || '/opt/documenso/cert.p12';
// We don't want to make the development server suddenly crash when using the `dx` script
// so we retain this when NODE_ENV isn't set to production which it should be in most production
// deployments.
//
// Our docker image automatically sets this so it shouldn't be an issue for self-hosters.
if (env('NODE_ENV') !== 'production') {
certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || './example/cert.p12';
}
try {
cert = Buffer.from(fs.readFileSync(certPath));
} catch {
console.error('Certificate error: Failed to read certificate file');
throw new Error('Document signing failed: Certificate file not accessible');
}
}
const signature = signWithP12({
cert,
content: pdfWithoutSignature,
password: env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || undefined,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`)),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
return signedPdf;
};
+32
View File
@@ -0,0 +1,32 @@
import { P12Signer } from '@libpdf/core';
import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
const loadP12 = (): Uint8Array => {
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
if (localFileContents) {
return Buffer.from(localFileContents, 'base64');
}
const localFilePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH');
if (localFilePath) {
return fs.readFileSync(localFilePath);
}
if (env('NODE_ENV') !== 'production') {
return fs.readFileSync('./example/cert.p12');
}
throw new Error('No certificate found for local signing');
};
export const createLocalSigner = async () => {
const p12 = loadP12();
return await P12Signer.create(p12, env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || '', {
buildChain: true,
});
};
+6
View File
@@ -41,6 +41,12 @@ declare namespace NodeJS {
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH?: string;
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY?: string;
NEXT_PUBLIC_SIGNING_CONTACT_INFO?: string;
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api';
@@ -35,6 +35,7 @@ import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import {
canRecipientBeModified,
canRecipientFieldsBeModified,
getRecipientsWithMissingFields,
} from '@documenso/lib/utils/recipients';
import { FieldToolTip } from '../../components/field/field-tooltip';
@@ -555,15 +556,11 @@ export const AddFieldsFormPartial = ({
};
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerEmail === signer.email,
),
);
// localFields already have recipientId set correctly (see field creation at line 338)
// Using the existing recipientId is important for handling duplicate email recipients
const recipientsMissingFields = getRecipientsWithMissingFields(recipients, localFields);
if (!everySignerHasSignature) {
if (recipientsMissingFields.length > 0) {
setIsMissingSignatureDialogVisible(true);
return;
}
+12
View File
@@ -117,6 +117,18 @@ services:
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY
sync: false
- key: NEXT_PUBLIC_SIGNING_CONTACT_INFO
sync: false
- key: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER
sync: false
# SMTP Optional
- key: NEXT_PRIVATE_SMTP_APIKEY_USER
+13 -21
View File
@@ -2,20 +2,12 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": [
"prebuild",
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
"dependsOn": ["prebuild", "^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"prebuild": {
"cache": false,
"dependsOn": [
"^prebuild"
]
"dependsOn": ["^prebuild"]
},
"lint": {
"cache": false
@@ -31,9 +23,7 @@
"persistent": true
},
"start": {
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
@@ -41,15 +31,11 @@
"cache": false
},
"test:e2e": {
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"cache": false
}
},
"globalDependencies": [
"**/.env.*local"
],
"globalDependencies": ["**/.env.*local"],
"globalEnv": [
"APP_VERSION",
"PORT",
@@ -75,6 +61,12 @@
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH",
"NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY",
"NEXT_PUBLIC_SIGNING_CONTACT_INFO",
"NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
@@ -143,4 +135,4 @@
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
"NEXT_PRIVATE_OIDC_PROMPT"
]
}
}