mirror of
https://github.com/documenso/documenso.git
synced 2026-06-26 14:22:14 +10:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d38e18f93 | |||
| 0a3e0b8727 | |||
| b538580a1e | |||
| 42d6e1cbbd | |||
| 67da488f63 | |||
| fd3ebc08ec | |||
| a7963b385a | |||
| 9035240b4d | |||
| ed7a0011c7 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
The recipients are added with the "CC" role by default, but you can select a different role for each recipient.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 555 KiB |
BIN
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));
|
||||
|
||||
+236
-284
@@ -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.
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Generated
+569
-550
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 "Document‑ID"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
Vendored
+6
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user