feat: multisign embedding (#1823)

Adds the ability to use a multisign embedding for cases where multiple
documents need to be signed in a convenient manner.
This commit is contained in:
Lucas Smith
2025-06-05 12:58:52 +10:00
committed by GitHub
parent 695ed418e2
commit ce66da0055
14 changed files with 1257 additions and 13 deletions

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
top: 0,
left: 0,
height: 0,
width: 0,
});
const calculateBounds = () => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
}
if (withScroll) {
return getBoundingClientRect($el);
}
const { top, left, width, height } = $el.getBoundingClientRect();
return {
top,
left,
width,
height,
};
};
useEffect(() => {
setBounds(calculateBounds());
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
setBounds(calculateBounds());
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateBounds]);
useEffect(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
return;
}
const observer = new ResizeObserver(() => {
setBounds(calculateBounds());
});
observer.observe($el);
return () => {
observer.disconnect();
};
}, [calculateBounds]);
return bounds;
};

View File

@ -1,7 +1,9 @@
import { router } from '../trpc';
import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({
createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
applyMultiSignSignature: applyMultiSignSignatureRoute,
getMultiSignDocument: getMultiSignDocumentRoute,
});

View File

@ -0,0 +1,102 @@
import { FieldType, ReadStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZApplyMultiSignSignatureRequestSchema,
ZApplyMultiSignSignatureResponseSchema,
} from './apply-multi-sign-signature.types';
export const applyMultiSignSignatureRoute = procedure
.input(ZApplyMultiSignSignatureRequestSchema)
.output(ZApplyMultiSignSignatureResponseSchema)
.mutation(async ({ input, ctx: { metadata } }) => {
try {
const { tokens, signature, isBase64 } = input;
// Get all documents and recipients for the tokens
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentByToken({ token });
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check if all documents have been viewed
const hasUnviewedDocuments = envelopes.some(
(envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED,
);
if (hasUnviewedDocuments) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'All documents must be viewed before signing',
});
}
// If we require action auth we should abort here for now
for (const envelope of envelopes) {
const derivedRecipientActionAuth = extractDocumentAuthMethods({
documentAuth: envelope.document.authOptions,
recipientAuth: envelope.recipient.authOptions,
});
if (
derivedRecipientActionAuth.recipientAccessAuthRequired ||
derivedRecipientActionAuth.recipientActionAuthRequired
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Documents that require additional authentication cannot be multi signed at the moment',
});
}
}
// Sign all signature fields for each document
await Promise.all(
envelopes.map(async (envelope) => {
if (envelope.recipient.signingStatus === SigningStatus.REJECTED) {
return;
}
const signatureFields = await prisma.field.findMany({
where: {
documentId: envelope.document.id,
recipientId: envelope.recipient.id,
type: FieldType.SIGNATURE,
inserted: false,
},
});
await Promise.all(
signatureFields.map(async (field) =>
signFieldWithToken({
token: envelope.recipient.token,
fieldId: field.id,
value: signature,
isBase64,
requestMetadata: metadata.requestMetadata,
}),
),
);
}),
);
return { success: true };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to apply multi-sign signature',
});
}
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
export const ZApplyMultiSignSignatureRequestSchema = z.object({
tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }),
signature: z.string().min(1, { message: 'Signature is required' }),
isBase64: z.boolean().optional().default(false),
});
export const ZApplyMultiSignSignatureResponseSchema = z.object({
success: z.boolean(),
});
export type TApplyMultiSignSignatureRequestSchema = z.infer<
typeof ZApplyMultiSignSignatureRequestSchema
>;
export type TApplyMultiSignSignatureResponseSchema = z.infer<
typeof ZApplyMultiSignSignatureResponseSchema
>;

View File

@ -0,0 +1,62 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { procedure } from '../trpc';
import {
ZGetMultiSignDocumentRequestSchema,
ZGetMultiSignDocumentResponseSchema,
} from './get-multi-sign-document.types';
export const getMultiSignDocumentRoute = procedure
.input(ZGetMultiSignDocumentRequestSchema)
.output(ZGetMultiSignDocumentResponseSchema)
.query(async ({ input, ctx: { metadata } }) => {
try {
const { token } = input;
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }).catch(() => []),
]);
if (!document || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
await viewedDocument({
token,
requestMetadata: metadata.requestMetadata,
});
// Transform fields to match our schema
const transformedFields = fields.map((field) => ({
...field,
recipient,
}));
return {
...document,
folder: null,
fields: transformedFields,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to get document details',
});
}
});

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
export const ZGetMultiSignDocumentRequestSchema = z.object({
token: z.string().min(1, { message: 'Token is required' }),
});
export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
password: true,
dateFormat: true,
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
}).nullable(),
fields: z.array(
FieldSchema.extend({
recipient: ZRecipientLiteSchema,
signature: SignatureSchema.nullable(),
}),
),
});
export type TGetMultiSignDocumentRequestSchema = z.infer<typeof ZGetMultiSignDocumentRequestSchema>;
export type TGetMultiSignDocumentResponseSchema = z.infer<
typeof ZGetMultiSignDocumentResponseSchema
>;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom';
@ -20,24 +20,37 @@ export function FieldContainerPortal({
children,
className = '',
}: FieldContainerPortalProps) {
const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
const style = useMemo(() => {
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
const bounds = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
if (portalBounds) {
bounds.top = `${coords.y - portalBounds.top}px`;
bounds.left = `${coords.x - portalBounds.left}px`;
}
return bounds;
}, [coords, isCheckboxOrRadioField]);
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
alternativePortalRoot ?? document.body,
);
}

View File

@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm', className)} {...props} />
<div ref={ref} className={cn('mt-2 text-sm', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';