mirror of
https://github.com/documenso/documenso.git
synced 2026-08-21 14:01:48 +10:00
Merge branch 'main' into feature/pdf-placeholder-selection-fields
This commit is contained in:
@@ -43,6 +43,20 @@ export enum AppErrorCode {
|
||||
*/
|
||||
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
|
||||
|
||||
/**
|
||||
* A completion request was made for a recipient that still has required
|
||||
* fields which have not been inserted. Usually indicates the client's field
|
||||
* state is out of sync with the server (e.g. a field insert failed to
|
||||
* persist before submission).
|
||||
*/
|
||||
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
|
||||
|
||||
/**
|
||||
* A completion request was made by a recipient in a sequential signing flow
|
||||
* before the preceding recipients have signed.
|
||||
*/
|
||||
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
|
||||
|
||||
/**
|
||||
* A signer recipient does not have a signature field assigned. Thrown when
|
||||
* distributing an envelope or using a direct template where at least one
|
||||
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -307,6 +323,8 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
|
||||
AppErrorCode.RECIPIENT_OUT_OF_TURN,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@aws-sdk/client-s3": "^3.998.0",
|
||||
"@aws-sdk/client-sesv2": "^3.998.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.998.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"ai": "^7.0.58",
|
||||
"bullmq": "^5.71.1",
|
||||
"colord": "^2.9.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^19.2.7",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"sharp": "0.35.3",
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
@@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// The most common cause is a stale signing page: the document was deleted,
|
||||
// or the recipient was removed, after the link was opened. Surface a
|
||||
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found for the provided signing token',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Document ${envelope.id} has no recipient with the provided token`,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
|
||||
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
|
||||
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
|
||||
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
|
||||
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
|
||||
// Unreachable: guarded by the status check above.
|
||||
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
|
||||
};
|
||||
|
||||
throw new AppError(envelopeStatusErrorCode[envelope.status], {
|
||||
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
@@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
|
||||
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
|
||||
message: `Recipient ${recipient.id} has unsigned fields`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/cancel',
|
||||
summary: 'Cancel envelope',
|
||||
description: 'Cancel a pending envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCancelEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
|
||||
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
|
||||
});
|
||||
|
||||
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/delete',
|
||||
summary: 'Delete envelope',
|
||||
description: 'Delete an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDeleteEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to delete.'),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/update',
|
||||
summary: 'Update envelope',
|
||||
description: 'Update envelope properties and settings',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZUpdateEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to update.'),
|
||||
data: z
|
||||
.object({
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
folderId: z.string().nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
globalAccessAuth: z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.describe('The authentication methods required to access the envelope.')
|
||||
.optional(),
|
||||
globalActionAuth: z
|
||||
.array(ZDocumentActionAuthTypesSchema)
|
||||
.describe('The authentication methods required to sign the envelope.')
|
||||
.optional(),
|
||||
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
|
||||
})
|
||||
.describe('The envelope properties to update.')
|
||||
.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
|
||||
|
||||
@@ -603,7 +603,7 @@ export const recipientRouter = router({
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
@@ -611,6 +611,16 @@ export const recipientRouter = router({
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
|
||||
// The most common cause is a stale signing page: the document was
|
||||
// deleted, or the recipient was removed, after the link was opened.
|
||||
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found for the provided signing token',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
|
||||
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CheckSquareIcon,
|
||||
ChevronDownIcon,
|
||||
ContactIcon,
|
||||
DiscIcon,
|
||||
EyeOffIcon,
|
||||
HashIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
TypeIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ElementType, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
|
||||
import { extractInitials } from '../../../lib/utils/recipient-formatter';
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Avatar, AvatarFallback } from '../../primitives/avatar';
|
||||
import { Badge } from '../../primitives/badge';
|
||||
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
|
||||
import { PopoverHover } from '../../primitives/popover';
|
||||
|
||||
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
|
||||
showRecipientColors?: boolean;
|
||||
}
|
||||
|
||||
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
|
||||
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return recipient.name;
|
||||
}
|
||||
|
||||
return recipient.email;
|
||||
const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
|
||||
[FieldType.SIGNATURE]: SignatureIcon,
|
||||
[FieldType.FREE_SIGNATURE]: SignatureIcon,
|
||||
[FieldType.INITIALS]: ContactIcon,
|
||||
[FieldType.TEXT]: TypeIcon,
|
||||
[FieldType.DATE]: CalendarDaysIcon,
|
||||
[FieldType.EMAIL]: MailIcon,
|
||||
[FieldType.NAME]: UserIcon,
|
||||
[FieldType.NUMBER]: HashIcon,
|
||||
[FieldType.RADIO]: DiscIcon,
|
||||
[FieldType.CHECKBOX]: CheckSquareIcon,
|
||||
[FieldType.DROPDOWN]: ChevronDownIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
}: EnvelopeRecipientFieldTooltipProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const FieldIcon = FIELD_TYPE_ICONS[field.type];
|
||||
|
||||
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
|
||||
|
||||
const [coords, setCoords] = useState({
|
||||
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
|
||||
className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
|
||||
sideOffset: 20,
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
{showFieldStatus && (
|
||||
<Badge
|
||||
className="mx-auto mb-1 py-0.5"
|
||||
variant={
|
||||
field?.fieldMeta?.readOnly
|
||||
? 'neutral'
|
||||
: field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{field?.fieldMeta?.readOnly ? (
|
||||
<>
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Read Only</Trans>
|
||||
</>
|
||||
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClockIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<p className="text-center font-semibold">
|
||||
<span>
|
||||
<p className="min-w-0 flex-1 truncate font-medium">
|
||||
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
|
||||
</span>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
|
||||
{showFieldStatus && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||
{field?.fieldMeta?.readOnly ? (
|
||||
<>
|
||||
<LockIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Read Only</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
<Trans>Signed</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
<Trans>Pending</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
||||
onClick={() => setHideField(true)}
|
||||
title="Hide field"
|
||||
>
|
||||
<EyeOffIcon className="h-3 w-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
|
||||
|
||||
{!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
|
||||
<p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
onClick={() => setHideField(true)}
|
||||
title={t`Hide field`}
|
||||
>
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user