-
-
- Sign document
-
+ {/* Widget */}
+
+
+ {/* Header */}
+
+
+
+ {isAssistantMode ? (
+ Assist with signing
+ ) : (
+ Sign document
+ )}
+
-
-
-
-
-
-
- Sign the document to complete the process.
-
-
-
-
-
- {/* Form */}
-
-
-
-
-
- !isNameLocked && setFullName(e.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- setSignature(value);
- }}
- onValidityChange={(isValid) => {
- setSignatureValid(isValid);
- }}
- allowTypedSignature={Boolean(
- metadata &&
- 'typedSignatureEnabled' in metadata &&
- metadata.typedSignatureEnabled,
- )}
+
-
+ ) : (
+ setIsExpanded(true)}
+ />
+ )}
+
+
+
- {hasSignatureField && !signatureValid && (
-
-
- Signature is too small. Please provide a more complete signature.
-
+
+
+ {isAssistantMode ? (
+ Help complete the document for other signers.
+ ) : (
+ Sign the document to complete the process.
+ )}
+
+
+
+
+
+ {/* Form */}
+
+
+ {isAssistantMode && (
+
+
+
+
)}
+
+ {!isAssistantMode && (
+ <>
+
+
+
+ !isNameLocked && setFullName(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setSignature(value);
+ }}
+ onValidityChange={(isValid) => {
+ setSignatureValid(isValid);
+ }}
+ allowTypedSignature={Boolean(
+ metadata &&
+ 'typedSignatureEnabled' in metadata &&
+ metadata.typedSignatureEnabled,
+ )}
+ />
+
+
+
+ {hasSignatureField && !signatureValid && (
+
+
+ Signature is too small. Please provide a more complete signature.
+
+
+ )}
+
+ >
+ )}
-
-
+
-
- {pendingFields.length > 0 ? (
-
- ) : (
-
- )}
+
+ {pendingFields.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {showPendingFieldTooltip && pendingFields.length > 0 && (
+
+ Click to insert field
+
+ )}
+
+
+ {/* Fields */}
+
-
- {showPendingFieldTooltip && pendingFields.length > 0 && (
-
- Click to insert field
-
- )}
-
-
- {/* Fields */}
-
+ {!hidePoweredBy && (
+
+ Powered by
+
+
+ )}
-
- {!hidePoweredBy && (
-
- Powered by
-
-
- )}
-
+
);
};
diff --git a/apps/web/src/app/embed/sign/[[...url]]/page.tsx b/apps/web/src/app/embed/sign/[[...url]]/page.tsx
index c07cd0be3..0e9ac7a60 100644
--- a/apps/web/src/app/embed/sign/[[...url]]/page.tsx
+++ b/apps/web/src/app/embed/sign/[[...url]]/page.tsx
@@ -8,17 +8,20 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
+import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
-import { DocumentStatus } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall';
+import { EmbedWaitingForTurn } from '../../waiting-for-turn';
import { EmbedSignDocumentClientPage } from './client';
export type EmbedSignDocumentPageProps = {
@@ -85,6 +88,19 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
);
}
+ const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
+
+ if (!isRecipientsTurnToSign) {
+ return
;
+ }
+
+ const allRecipients =
+ recipient.role === RecipientRole.ASSISTANT
+ ? await getRecipientsForAssistant({
+ token,
+ })
+ : [];
+
const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
: null;
@@ -112,6 +128,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
+ allRecipients={allRecipients}
/>
diff --git a/apps/web/src/app/embed/waiting-for-turn.tsx b/apps/web/src/app/embed/waiting-for-turn.tsx
new file mode 100644
index 000000000..fe034f771
--- /dev/null
+++ b/apps/web/src/app/embed/waiting-for-turn.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { Trans } from '@lingui/macro';
+
+export const EmbedWaitingForTurn = () => {
+ const [hasPostedMessage, setHasPostedMessage] = useState(false);
+
+ useEffect(() => {
+ if (window.parent && !hasPostedMessage) {
+ window.parent.postMessage(
+ {
+ action: 'document-waiting-for-turn',
+ data: null,
+ },
+ '*',
+ );
+ }
+
+ setHasPostedMessage(true);
+ }, [hasPostedMessage]);
+
+ if (!hasPostedMessage) {
+ return null;
+ }
+
+ return (
+
+
+ Waiting for Your Turn
+
+
+
+
+
+ It's currently not your turn to sign. Please check back soon as this document should be
+ available for you to sign shortly.
+
+
+
+
+ Please check with the parent application for more information.
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx
index e07ed9f1b..7d3b91d52 100644
--- a/apps/web/src/components/(dashboard)/common/command-menu.tsx
+++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx
@@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const [search, setSearch] = useState('');
const [pages, setPages] = useState
([]);
- const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
+ const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,
diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
index fa991099b..390c138b3 100644
--- a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
+++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
@@ -67,7 +67,7 @@ export const TransferTeamDialog = ({
const {
data,
refetch: refetchTeamMembers,
- isLoading: loadingTeamMembers,
+ isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,
diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx
index 8bda3a424..cb607a125 100644
--- a/apps/web/src/components/document/document-history-sheet.tsx
+++ b/apps/web/src/components/document/document-history-sheet.tsx
@@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({
/>
),
)
+ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
+
+ ))
.exhaustive()}
{isUserDetailsVisible && (
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
index da4eae6d7..d53f33d11 100644
--- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
+++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
@@ -540,12 +540,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
}
+
await page
- .getByPlaceholder('Email')
+ .getByLabel('Email')
+ .nth(i - 1)
+ .focus();
+
+ await page
+ .getByLabel('Email')
.nth(i - 1)
.fill(`user${i}@example.com`);
+
await page
- .getByPlaceholder('Name')
+ .getByLabel('Name')
.nth(i - 1)
.fill(`User ${i}`);
}
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx
index c8b4a402d..998d8549c 100644
--- a/packages/email/template-components/template-document-invite.tsx
+++ b/packages/email/template-components/template-document-invite.tsx
@@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => Continue by viewing the document.)
.with(RecipientRole.APPROVER, () => Continue by approving the document.)
.with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => (
+ Continue by assisting with the document.
+ ))
.exhaustive()}
@@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => View Document)
.with(RecipientRole.APPROVER, () => Approve Document)
.with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => Assist Document)
.exhaustive()}
diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts
index 8ae654977..9b91d2cb9 100644
--- a/packages/lib/constants/document-audit-logs.ts
+++ b/packages/lib/constants/document-audit-logs.ts
@@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request',
},
+ [DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
+ description: 'Assisting request',
+ },
[DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC',
},
diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts
index ad994c98d..5adc8a735 100644
--- a/packages/lib/constants/recipient-roles.ts
+++ b/packages/lib/constants/recipient-roles.ts
@@ -32,12 +32,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
},
+ [RecipientRole.ASSISTANT]: {
+ actionVerb: msg`Assist`,
+ actioned: msg`Assisted`,
+ progressiveVerb: msg`Assisting`,
+ roleName: msg`Assistant`,
+ roleNamePlural: msg`Assistants`,
+ },
} satisfies Record;
+export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = {
+ [RecipientRole.SIGNER]: `SIGNING_REQUEST`,
+ [RecipientRole.VIEWER]: `VIEW_REQUEST`,
+ [RecipientRole.APPROVER]: `APPROVE_REQUEST`,
+} as const;
+
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
+ [RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
} as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = {
@@ -45,4 +59,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
[RecipientRole.APPROVER]: msg`I am an approver of this document`,
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
+ [RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
} satisfies Record;
diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx
index 6f00cbc78..e803f5d7a 100644
--- a/packages/lib/server-only/document/resend-document.tsx
+++ b/packages/lib/server-only/document/resend-document.tsx
@@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
-import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
diff --git a/packages/lib/server-only/field/get-fields-for-token.ts b/packages/lib/server-only/field/get-fields-for-token.ts
index 635773f8f..6abb07281 100644
--- a/packages/lib/server-only/field/get-fields-for-token.ts
+++ b/packages/lib/server-only/field/get-fields-for-token.ts
@@ -1,15 +1,55 @@
import { prisma } from '@documenso/prisma';
+import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetFieldsForTokenOptions = {
token: string;
};
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
+ if (!token) {
+ throw new Error('Missing token');
+ }
+
+ const recipient = await prisma.recipient.findFirst({
+ where: { token },
+ });
+
+ if (!recipient) {
+ return [];
+ }
+
+ if (recipient.role === RecipientRole.ASSISTANT) {
+ return await prisma.field.findMany({
+ where: {
+ OR: [
+ {
+ type: {
+ not: FieldType.SIGNATURE,
+ },
+ recipient: {
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ },
+ documentId: recipient.documentId,
+ },
+ {
+ recipientId: recipient.id,
+ },
+ ],
+ },
+ include: {
+ signature: true,
+ },
+ });
+ }
+
return await prisma.field.findMany({
where: {
- recipient: {
- token,
- },
+ recipientId: recipient.id,
},
include: {
signature: true,
diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts
index 654dfec20..ba56305e1 100644
--- a/packages/lib/server-only/field/remove-signed-field-with-token.ts
+++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts
@@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
-import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
@@ -17,11 +17,28 @@ export const removeSignedFieldWithToken = async ({
fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => {
+ const recipient = await prisma.recipient.findFirstOrThrow({
+ where: {
+ token,
+ },
+ });
+
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
- token,
+ ...(recipient.role !== RecipientRole.ASSISTANT
+ ? {
+ id: recipient.id,
+ }
+ : {
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ }),
},
},
include: {
@@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({
},
});
- const { document, recipient } = field;
+ const { document } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
@@ -40,7 +57,10 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document ${document.id} must be pending`);
}
- if (recipient?.signingStatus === SigningStatus.SIGNED) {
+ if (
+ recipient?.signingStatus === SigningStatus.SIGNED ||
+ field.recipient.signingStatus === SigningStatus.SIGNED
+ ) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -66,20 +86,22 @@ export const removeSignedFieldWithToken = async ({
},
});
- await tx.documentAuditLog.create({
- data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
- documentId: document.id,
- user: {
- name: recipient?.name,
- email: recipient?.email,
- },
- requestMetadata,
- data: {
- field: field.type,
- fieldId: field.secondaryId,
- },
- }),
- });
+ if (recipient.role !== RecipientRole.ASSISTANT) {
+ await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
+ documentId: document.id,
+ user: {
+ name: recipient.name,
+ email: recipient.email,
+ },
+ requestMetadata,
+ data: {
+ field: field.type,
+ fieldId: field.secondaryId,
+ },
+ }),
+ });
+ }
});
};
diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts
index f5b170ba5..f94dc8de0 100644
--- a/packages/lib/server-only/field/sign-field-with-token.ts
+++ b/packages/lib/server-only/field/sign-field-with-token.ts
@@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma';
-import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
+import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
@@ -56,20 +56,41 @@ export const signFieldWithToken = async ({
authOptions,
requestMetadata,
}: SignFieldWithTokenOptions) => {
+ const recipient = await prisma.recipient.findFirstOrThrow({
+ where: {
+ token,
+ },
+ });
+
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
- token,
+ ...(recipient.role !== RecipientRole.ASSISTANT
+ ? {
+ id: recipient.id,
+ }
+ : {
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ }),
},
},
include: {
- document: true,
+ document: {
+ include: {
+ recipients: true,
+ },
+ },
recipient: true,
},
});
- const { document, recipient } = field;
+ const { document } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
@@ -87,7 +108,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${document.id} must be pending for signing`);
}
- if (recipient?.signingStatus === SigningStatus.SIGNED) {
+ if (
+ recipient.signingStatus === SigningStatus.SIGNED ||
+ field.recipient.signingStatus === SigningStatus.SIGNED
+ ) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -183,6 +207,8 @@ export const signFieldWithToken = async ({
throw new Error('Typed signatures are not allowed. Please draw your signature');
}
+ const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
+
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
@@ -219,11 +245,14 @@ export const signFieldWithToken = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
+ type:
+ assistant && field.recipientId !== assistant.id
+ ? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
+ : DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
- email: recipient.email,
- name: recipient.name,
+ email: assistant?.email ?? recipient.email,
+ name: assistant?.name ?? recipient.name,
},
requestMetadata,
data: {
diff --git a/packages/lib/server-only/recipient/get-recipient-by-token.ts b/packages/lib/server-only/recipient/get-recipient-by-token.ts
index d12151b41..d24a08603 100644
--- a/packages/lib/server-only/recipient/get-recipient-by-token.ts
+++ b/packages/lib/server-only/recipient/get-recipient-by-token.ts
@@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
where: {
token,
},
+ include: {
+ fields: true,
+ },
});
};
diff --git a/packages/lib/server-only/recipient/get-recipients-for-assistant.ts b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts
new file mode 100644
index 000000000..6c15af639
--- /dev/null
+++ b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts
@@ -0,0 +1,57 @@
+import { prisma } from '@documenso/prisma';
+import { FieldType } from '@documenso/prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+
+export interface GetRecipientsForAssistantOptions {
+ token: string;
+}
+
+export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => {
+ const assistant = await prisma.recipient.findFirst({
+ where: {
+ token,
+ },
+ });
+
+ if (!assistant) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Assistant not found',
+ });
+ }
+
+ let recipients = await prisma.recipient.findMany({
+ where: {
+ documentId: assistant.documentId,
+ signingOrder: {
+ gte: assistant.signingOrder ?? 0,
+ },
+ },
+ include: {
+ fields: {
+ where: {
+ OR: [
+ {
+ recipientId: assistant.id,
+ },
+ {
+ type: {
+ not: FieldType.SIGNATURE,
+ },
+ documentId: assistant.documentId,
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ // Omit the token for recipients other than the assistant so
+ // it doesn't get sent to the client.
+ recipients = recipients.map((recipient) => ({
+ ...recipient,
+ token: recipient.id === assistant.id ? token : '',
+ }));
+
+ return recipients;
+};
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index 73073f7a8..e0b251f5d 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -28,6 +28,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
+ 'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
@@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
+ 'ASSISTING_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
]);
@@ -313,6 +315,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
}),
});
+/**
+ * Event: Document field prefilled by assistant.
+ */
+export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
+ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED),
+ data: ZBaseRecipientDataSchema.extend({
+ fieldId: z.string(),
+
+ // Organised into union to allow us to extend each field if required.
+ field: z.union([
+ z.object({
+ type: z.literal(FieldType.INITIALS),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.EMAIL),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.DATE),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.NAME),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.TEXT),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.RADIO),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.CHECKBOX),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.DROPDOWN),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.NUMBER),
+ data: z.string(),
+ }),
+ ]),
+ fieldSecurity: z.preprocess(
+ (input) => {
+ const legacyNoneSecurityType = JSON.stringify({
+ type: 'NONE',
+ });
+
+ // Replace legacy 'NONE' field security type with undefined.
+ if (
+ typeof input === 'object' &&
+ input !== null &&
+ JSON.stringify(input) === legacyNoneSecurityType
+ ) {
+ return undefined;
+ }
+
+ return input;
+ },
+ z
+ .object({
+ type: ZRecipientActionAuthTypesSchema,
+ })
+ .optional(),
+ ),
+ }),
+});
+
export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
data: ZGenericFromToSchema,
@@ -493,6 +572,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
+ ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
ZDocumentAuditLogEventDocumentVisibilitySchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts
index 339bf453b..44ae23ac0 100644
--- a/packages/lib/utils/document-audit-logs.ts
+++ b/packages/lib/utils/document-audit-logs.ts
@@ -314,6 +314,10 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Field unsigned`,
identified: msg`${prefix} unsigned a field`,
}))
+ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
+ anonymous: msg`Field prefilled by assistant`,
+ identified: msg`${prefix} prefilled a field`,
+ }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`,
identified: msg`${prefix} updated the document visibility`,
diff --git a/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql
new file mode 100644
index 000000000..b5eb3e491
--- /dev/null
+++ b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 44e0bfeee..0cdb1521e 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -417,6 +417,7 @@ enum RecipientRole {
SIGNER
VIEWER
APPROVER
+ ASSISTANT
}
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
diff --git a/packages/prisma/types/recipient-with-fields.ts b/packages/prisma/types/recipient-with-fields.ts
new file mode 100644
index 000000000..ed4314897
--- /dev/null
+++ b/packages/prisma/types/recipient-with-fields.ts
@@ -0,0 +1,5 @@
+import type { Field, Recipient } from '@documenso/prisma/client';
+
+export type RecipientWithFields = Recipient & {
+ fields: Field[];
+};
diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx
index 4d72b7be1..8559a9b59 100644
--- a/packages/ui/components/recipient/recipient-role-select.tsx
+++ b/packages/ui/components/recipient/recipient-role-select.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { forwardRef } from 'react';
+import { forwardRef } from 'react';
import { Trans } from '@lingui/macro';
import type { SelectProps } from '@radix-ui/react-select';
@@ -11,12 +11,15 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+import { cn } from '../../lib/utils';
+
export type RecipientRoleSelectProps = SelectProps & {
hideCCRecipients?: boolean;
+ isAssistantEnabled?: boolean;
};
export const RecipientRoleSelect = forwardRef(
- ({ hideCCRecipients, ...props }, ref) => (
+ ({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
),
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index 79a22e83c..f6b1572d8 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -508,7 +508,15 @@ export const AddFieldsFormPartial = ({
}, []);
useEffect(() => {
- setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
+ const recipientsByRoleToDisplay = recipients.filter(
+ (recipient) =>
+ recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
+ );
+
+ setSelectedSigner(
+ recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
+ recipientsByRoleToDisplay[0],
+ );
}, [recipients]);
const recipientsByRole = useMemo(() => {
@@ -517,6 +525,7 @@ export const AddFieldsFormPartial = ({
VIEWER: [],
SIGNER: [],
APPROVER: [],
+ ASSISTANT: [],
};
recipients.forEach((recipient) => {
@@ -529,7 +538,12 @@ export const AddFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
- .filter(([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER)
+ .filter(
+ ([role]) =>
+ role !== RecipientRole.CC &&
+ role !== RecipientRole.VIEWER &&
+ role !== RecipientRole.ASSISTANT,
+ )
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -544,12 +558,6 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
- const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
-
- const handleTypedSignatureChange = (value: boolean) => {
- form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
- };
-
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
@@ -687,9 +695,7 @@ export const AddFieldsFormPartial = ({
)}
{!selectedSigner?.email && (
-
- {selectedSigner?.email}
-
+ {selectedSigner?.email}
)}
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index 395ed6dcd..5ece17491 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -41,6 +41,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
+import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
@@ -123,6 +124,7 @@ export const AddSignersFormPartial = ({
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
+ const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const {
setValue,
@@ -134,6 +136,10 @@ export const AddSignersFormPartial = ({
const watchedSigners = watch('signers');
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
+ const hasAssistantRole = useMemo(() => {
+ return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
+ }, [watchedSigners]);
+
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return signers
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
@@ -233,6 +239,7 @@ export const AddSignersFormPartial = ({
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
+ // Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) {
insertIndex++;
@@ -240,126 +247,116 @@ export const AddSignersFormPartial = ({
items.splice(insertIndex, 0, reorderedSigner);
- const updatedSigners = items.map((item, index) => ({
- ...item,
- signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1,
+ const updatedSigners = items.map((signer, index) => ({
+ ...signer,
+ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
}));
- updatedSigners.forEach((item, index) => {
- const keys: (keyof typeof item)[] = [
- 'formId',
- 'nativeId',
- 'email',
- 'name',
- 'role',
- 'signingOrder',
- 'actionAuth',
- ];
- keys.forEach((key) => {
- form.setValue(`signers.${index}.${key}` as const, item[key]);
- });
- });
+ form.setValue('signers', updatedSigners);
- const currentLength = form.getValues('signers').length;
- if (currentLength > updatedSigners.length) {
- for (let i = updatedSigners.length; i < currentLength; i++) {
- form.unregister(`signers.${i}`);
- }
+ const lastSigner = updatedSigners[updatedSigners.length - 1];
+ if (lastSigner.role === RecipientRole.ASSISTANT) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
await form.trigger('signers');
},
- [form, canRecipientBeModified, watchedSigners],
+ [form, canRecipientBeModified, watchedSigners, toast],
);
- const triggerDragAndDrop = useCallback(
- (fromIndex: number, toIndex: number) => {
- if (!$sensorApi.current) {
+ const handleRoleChange = useCallback(
+ (index: number, role: RecipientRole) => {
+ const currentSigners = form.getValues('signers');
+ const signingOrder = form.getValues('signingOrder');
+
+ // Handle parallel to sequential conversion for assistants
+ if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
+ form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
+ toast({
+ title: _(msg`Signing order is enabled.`),
+ description: _(msg`You cannot add assistants when signing order is disabled.`),
+ variant: 'destructive',
+ });
return;
}
- const draggableId = signers[fromIndex].id;
+ const updatedSigners = currentSigners.map((signer, idx) => ({
+ ...signer,
+ role: idx === index ? role : signer.role,
+ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
+ }));
- const preDrag = $sensorApi.current.tryGetLock(draggableId);
+ form.setValue('signers', updatedSigners);
- if (!preDrag) {
- return;
+ if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
-
- const drag = preDrag.snapLift();
-
- setTimeout(() => {
- // Move directly to the target index
- if (fromIndex < toIndex) {
- for (let i = fromIndex; i < toIndex; i++) {
- drag.moveDown();
- }
- } else {
- for (let i = fromIndex; i > toIndex; i--) {
- drag.moveUp();
- }
- }
-
- setTimeout(() => {
- drag.drop();
- }, 500);
- }, 0);
},
- [signers],
- );
-
- const updateSigningOrders = useCallback(
- (newIndex: number, oldIndex: number) => {
- const updatedSigners = form.getValues('signers').map((signer, index) => {
- if (index === oldIndex) {
- return { ...signer, signingOrder: newIndex + 1 };
- } else if (index >= newIndex && index < oldIndex) {
- return {
- ...signer,
- signingOrder: !canRecipientBeModified(signer.nativeId)
- ? signer.signingOrder
- : (signer.signingOrder ?? index + 1) + 1,
- };
- } else if (index <= newIndex && index > oldIndex) {
- return {
- ...signer,
- signingOrder: !canRecipientBeModified(signer.nativeId)
- ? signer.signingOrder
- : Math.max(1, (signer.signingOrder ?? index + 1) - 1),
- };
- }
- return signer;
- });
-
- updatedSigners.forEach((signer, index) => {
- form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
- });
- },
- [form, canRecipientBeModified],
+ [form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
- const newOrder = parseInt(newOrderString, 10);
-
- if (!newOrderString.trim()) {
+ const trimmedOrderString = newOrderString.trim();
+ if (!trimmedOrderString) {
return;
}
- if (Number.isNaN(newOrder)) {
- form.setValue(`signers.${index}.signingOrder`, index + 1);
+ const newOrder = Number(trimmedOrderString);
+ if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
- const newIndex = newOrder - 1;
- if (index !== newIndex) {
- updateSigningOrders(newIndex, index);
- triggerDragAndDrop(index, newIndex);
+ const currentSigners = form.getValues('signers');
+ const signer = currentSigners[index];
+
+ // Remove signer from current position and insert at new position
+ const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
+ const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
+ remainingSigners.splice(newPosition, 0, signer);
+
+ const updatedSigners = remainingSigners.map((s, idx) => ({
+ ...s,
+ signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
},
- [form, triggerDragAndDrop, updateSigningOrders],
+ [form, canRecipientBeModified, toast],
);
+ const handleSigningOrderDisable = useCallback(() => {
+ setShowSigningOrderConfirmation(false);
+
+ const currentSigners = form.getValues('signers');
+ const updatedSigners = currentSigners.map((signer) => ({
+ ...signer,
+ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
+ }));
+
+ form.setValue('signers', updatedSigners);
+ form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
+ }, [form]);
+
return (
<>
+ onCheckedChange={(checked) => {
+ if (!checked && hasAssistantRole) {
+ setShowSigningOrderConfirmation(true);
+ return;
+ }
+
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
- )
- }
+ );
+ }}
disabled={isSubmitting || hasDocumentBeenSent}
/>
@@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ handleRoleChange(index, value as RecipientRole)
+ }
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -710,6 +716,12 @@ export const AddSignersFormPartial = ({
)}
+
+
diff --git a/packages/ui/primitives/document-flow/signing-order-confirmation.tsx b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx
new file mode 100644
index 000000000..e127ec484
--- /dev/null
+++ b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx
@@ -0,0 +1,40 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@documenso/ui/primitives/alert-dialog';
+
+export type SigningOrderConfirmationProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+};
+
+export function SigningOrderConfirmation({
+ open,
+ onOpenChange,
+ onConfirm,
+}: SigningOrderConfirmationProps) {
+ return (
+
+
+
+ Warning
+
+ You have an assistant role on the signers list, removing the signing order will change
+ the assistant role to signer.
+
+
+
+ Cancel
+ Proceed
+
+
+
+ );
+}
diff --git a/packages/ui/primitives/radio-group.tsx b/packages/ui/primitives/radio-group.tsx
index 1daa806e1..176c6f2f0 100644
--- a/packages/ui/primitives/radio-group.tsx
+++ b/packages/ui/primitives/radio-group.tsx
@@ -19,18 +19,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, children: _children, ...props }, ref) => {
+>(({ className, ...props }, ref) => {
return (
-
+
);
diff --git a/packages/ui/primitives/recipient-role-icons.tsx b/packages/ui/primitives/recipient-role-icons.tsx
index 5bc4f34b9..f6db9df9a 100644
--- a/packages/ui/primitives/recipient-role-icons.tsx
+++ b/packages/ui/primitives/recipient-role-icons.tsx
@@ -1,4 +1,4 @@
-import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
+import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
import type { RecipientRole } from '.prisma/client';
@@ -7,4 +7,5 @@ export const ROLE_ICONS: Record = {
APPROVER: ,
CC: ,
VIEWER: ,
+ ASSISTANT: ,
};
diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx
index c26c567e1..13d96b56a 100644
--- a/packages/ui/primitives/template-flow/add-template-fields.tsx
+++ b/packages/ui/primitives/template-flow/add-template-fields.tsx
@@ -32,7 +32,7 @@ import {
import { nanoid } from '@documenso/lib/universal/id';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import type { Field, Recipient } from '@documenso/prisma/client';
-import { FieldType, RecipientRole } from '@documenso/prisma/client';
+import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -438,6 +438,7 @@ export const AddTemplateFieldsFormPartial = ({
VIEWER: [],
SIGNER: [],
APPROVER: [],
+ ASSISTANT: [],
};
recipients.forEach((recipient) => {
@@ -447,10 +448,25 @@ export const AddTemplateFieldsFormPartial = ({
return recipientsByRole;
}, [recipients]);
+ useEffect(() => {
+ const recipientsByRoleToDisplay = recipients.filter(
+ (recipient) =>
+ recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
+ );
+
+ setSelectedSigner(
+ recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
+ recipientsByRoleToDisplay[0],
+ );
+ }, [recipients]);
+
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
- ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
+ ([role]) =>
+ role !== RecipientRole.CC &&
+ role !== RecipientRole.VIEWER &&
+ role !== RecipientRole.ASSISTANT,
);
}, [recipientsByRole]);
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
index bf8dc0bd0..7312bf6ee 100644
--- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
@@ -29,6 +29,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
+import { toast } from '@documenso/ui/primitives/use-toast';
import { Checkbox } from '../checkbox';
import {
@@ -39,6 +40,7 @@ import {
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
+import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
import type { DocumentFlowStep } from '../document-flow/types';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { useStep } from '../stepper';
@@ -213,41 +215,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
-
const insertIndex = result.destination.index;
items.splice(insertIndex, 0, reorderedSigner);
- const updatedSigners = items.map((item, index) => ({
- ...item,
+ const updatedSigners = items.map((signer, index) => ({
+ ...signer,
signingOrder: index + 1,
}));
- updatedSigners.forEach((item, index) => {
- const keys: (keyof typeof item)[] = [
- 'formId',
- 'nativeId',
- 'email',
- 'name',
- 'role',
- 'signingOrder',
- 'actionAuth',
- ];
- keys.forEach((key) => {
- form.setValue(`signers.${index}.${key}` as const, item[key]);
- });
- });
+ form.setValue('signers', updatedSigners);
- const currentLength = form.getValues('signers').length;
- if (currentLength > updatedSigners.length) {
- for (let i = updatedSigners.length; i < currentLength; i++) {
- form.unregister(`signers.${i}`);
- }
+ const lastSigner = updatedSigners[updatedSigners.length - 1];
+ if (lastSigner.role === RecipientRole.ASSISTANT) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
await form.trigger('signers');
},
- [form, watchedSigners],
+ [form, watchedSigners, toast],
);
const triggerDragAndDrop = useCallback(
@@ -308,26 +299,94 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
- const newOrder = parseInt(newOrderString, 10);
-
- if (!newOrderString.trim()) {
+ const trimmedOrderString = newOrderString.trim();
+ if (!trimmedOrderString) {
return;
}
- if (Number.isNaN(newOrder)) {
- form.setValue(`signers.${index}.signingOrder`, index + 1);
+ const newOrder = Number(trimmedOrderString);
+ if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
- const newIndex = newOrder - 1;
- if (index !== newIndex) {
- updateSigningOrders(newIndex, index);
- triggerDragAndDrop(index, newIndex);
+ const currentSigners = form.getValues('signers');
+ const signer = currentSigners[index];
+
+ // Remove signer from current position and insert at new position
+ const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
+ const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
+ remainingSigners.splice(newPosition, 0, signer);
+
+ const updatedSigners = remainingSigners.map((s, idx) => ({
+ ...s,
+ signingOrder: idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
},
- [form, triggerDragAndDrop, updateSigningOrders],
+ [form, toast],
);
+ const handleRoleChange = useCallback(
+ (index: number, role: RecipientRole) => {
+ const currentSigners = form.getValues('signers');
+ const signingOrder = form.getValues('signingOrder');
+
+ // Handle parallel to sequential conversion for assistants
+ if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
+ form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
+ toast({
+ title: _(msg`Signing order is enabled.`),
+ description: _(msg`You cannot add assistants when signing order is disabled.`),
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ const updatedSigners = currentSigners.map((signer, idx) => ({
+ ...signer,
+ role: idx === index ? role : signer.role,
+ signingOrder: idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
+ }
+ },
+ [form, toast],
+ );
+
+ const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
+
+ const handleSigningOrderDisable = useCallback(() => {
+ setShowSigningOrderConfirmation(false);
+
+ const currentSigners = form.getValues('signers');
+ const updatedSigners = currentSigners.map((signer) => ({
+ ...signer,
+ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
+ }));
+
+ form.setValue('signers', updatedSigners);
+ form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
+ }, [form]);
+
return (
<>
+ onCheckedChange={(checked) => {
+ if (
+ !checked &&
+ watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT)
+ ) {
+ setShowSigningOrderConfirmation(true);
+ return;
+ }
+
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
- )
- }
+ );
+ }}
disabled={isSubmitting}
/>
@@ -556,7 +623,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ handleRoleChange(index, value as RecipientRole)
+ }
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
/>
@@ -677,6 +747,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
onGoNextClick={() => void onFormSubmit()}
/>
+
+
>
);
};