mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
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:
75
packages/lib/client-only/hooks/use-element-bounds.ts
Normal file
75
packages/lib/client-only/hooks/use-element-bounds.ts
Normal 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;
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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
|
||||
>;
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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
|
||||
>;
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
Reference in New Issue
Block a user