Compare commits

...

20 Commits

Author SHA1 Message Date
cbad065dac v1.9.1-rc.0 2025-02-03 10:13:16 +11:00
25a3861c91 fix: add css targets for embeds 2025-02-03 09:58:40 +11:00
b9ae277041 v1.9.0 2025-02-03 09:33:08 +11:00
7fad826d06 v1.9.0-rc.12 2025-02-01 15:53:18 +11:00
eb8ba2036a chore: add translations (#1619)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-02-01 15:52:21 +11:00
339759166c fix: temp field label/text truncation (#1565)
TEMP: Fix the truncation of the field label/text.
2025-02-01 14:35:19 +11:00
637e06f9c0 fix: unable to check on the checkbox field (#1593)
This change prevents race conditions between state updates and API
operations by updating local state immediately before making async
calls.
2025-02-01 14:34:42 +11:00
332e0657e0 feat: assistant role (#1588)
## Description

Introduces the ability for users with the **Assistant** role to prefill
fields on behalf of other signers. Assistants can fill in various field
types such as text, checkboxes, dates, and more, streamlining the
document preparation process before it reaches the final signers.

https://github.com/user-attachments/assets/c1321578-47ec-405b-a70a-7d9578385895
2025-02-01 14:31:18 +11:00
4017b250fb chore: api v2 docs (#1620)
chore update docs for api v2 announce
2025-01-31 09:11:47 +01:00
41373a7c6f fix: improve move to team display logic 2025-01-31 11:33:08 +11:00
7cc85ca6bc chore: extract translations 2025-01-30 16:03:36 +11:00
bc19fa0cbd feat: add Polish and Italian (#1618) 2025-01-30 15:21:37 +11:00
a60f58e20b chore: add translations (#1617) 2025-01-30 15:09:35 +11:00
aca902b5ff chore: add translations (#1585)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-01-30 13:24:46 +11:00
2f866c41b4 fix: create global settings on team creation (#1601)
The global team settings weren't created when creating a new team.

## Changes Made

The global team settings are now created when a new team is created.
2025-01-28 16:16:18 +11:00
7e4faef95f chore: add cancelled webhook event (#1608)
https://github.com/user-attachments/assets/9f2ff975-6688-4150-b4e3-0eb21e2b5503
2025-01-28 15:34:22 +11:00
bcef84787d feat: bulk send templates via csv (#1578)
Implements a bulk send feature allowing users to upload a CSV file to
create multiple documents from a template. Includes CSV template
generation, background processing, and email notifications.

<img width="563" alt="image"
src="https://github.com/user-attachments/assets/658cee71-6508-4a00-87da-b17c6762b7d8"
/>
<img width="578" alt="image"
src="https://github.com/user-attachments/assets/bbfac70b-c6a0-466a-be98-99ca4c4eb1b9"
/>
<img width="635" alt="image"
src="https://github.com/user-attachments/assets/65b2f55d-d491-45ac-84d6-1a31afe953dd"
/>


## Changes Made

- Added `TemplateBulkSendDialog` with CSV upload/download functionality
- Implemented bulk send job handler using background task system
- Created email template for completion notifications
- Added bulk send option to template view and actions dropdown
- Added CSV parsing with email/name validation

## Testing Performed

- CSV upload with valid/invalid data
- Bulk send with/without immediate sending
- Email notifications and error handling
- Team context integration
- File size and row count limits

Resolves #1550
2025-01-28 15:33:32 +11:00
70a3ac0525 fix: tidy document invite email render logic (#1597)
Updates one of our confusing ternaries to use `ts-pattern` for rendering
the conditional blocks making it easy to follow the logic occurring.

## Related Issue

N/A

## Changes Made

- Swapped ternary for `ts-pattern`

## Testing Performed

- Manually created a bunch of documents in configurations matching those
required to exhaust the `match` conditions.
2025-01-28 15:18:12 +11:00
c6fb101a99 fix: admin leaderboard query sorting (#1548) 2025-01-28 13:05:40 +11:00
2984af769c feat: add text align option to fields (#1610)
## Description

Adds the ability to align text to the left, center or right for relevant
fields.

Previously text was always centered which can be less desirable.

See attached debug document which has left, center and right text
alignments set for fields.

<img width="614" alt="image"
src="https://github.com/user-attachments/assets/361a030e-813d-458b-9c7a-ff4c9fa5e33c"
/>


## Related Issue

N/A

## Changes Made

- Added text align option
- Update the insert in pdf method to support different alignments
- Added a debug mode to field insertion

## Testing Performed

- Ran manual tests using the debug mode
2025-01-28 12:29:38 +11:00
95 changed files with 11810 additions and 2080 deletions

View File

@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
## CSS Class Targets
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
### Component Classes
| Class Name | Description |
| --------------------------------- | ----------------------------------------------------------------------- |
| `.embed--Root` | Main container for the embedded signing experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
Field components also expose several data attributes that can be used for styling different states:
| Data Attribute | Values | Description |
| ------------------- | ---------------------------------------------- | ------------------------------------ |
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
### Field Styling Example
```css
/* Style all field containers */
.field--FieldRootContainer {
transition: all 200ms ease;
}
/* Style specific field types */
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
background-color: rgba(0, 0, 0, 0.02);
}
/* Style inserted fields */
.field--FieldRootContainer[data-inserted='true'] {
background-color: var(--primary);
opacity: 0.2;
}
/* Style fields being validated */
.field--FieldRootContainer[data-validate='true'] {
border-color: orange;
}
```
### Example Usage
```css
/* Custom styles for the document widget */
.embed--DocumentWidget {
background-color: #ffffff;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Custom styles for the waiting screen */
.embed--WaitingForTurn {
background-color: #f9fafb;
padding: 2rem;
}
/* Responsive adjustments for the document container */
@media (min-width: 768px) {
.embed--DocumentContainer {
gap: 2rem;
}
}
```
## Related
- [React Integration](/developers/embedding/react)

View File

@ -3,6 +3,8 @@ title: Public API
description: Learn how to interact with your documents programmatically using the Documenso public API.
---
import { Callout, Steps } from 'nextra/components';
# Public API
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
@ -13,10 +15,24 @@ Documenso provides a public REST API enabling you to interact with your document
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
## Swagger Documentation
## API V1 - Stable
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
## API V2 - Beta
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
<Callout type="warning">
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)
💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
## Availability
The API is available to individual users and teams.
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.

View File

@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.signed`
- `document.completed`
- `document.rejected`
- `document.cancelled`
## Create a webhook subscription
@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
}
```
Example payload for the `document.rejected` event:
```json
{
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"externalId": null,
"userId": 3,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "cm6exvn93006hi02ru90a265a",
"createdAt": "2025-01-27T11:02:14.393Z",
"updatedAt": "2025-01-27T11:03:16.387Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "cm6exvn96006ji02rqvzjvwoy",
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
}
},
"recipients": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "mybirihix@mailinator.com",
"name": "Zorita Baird",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2025-01-27T11:03:27.730Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability
Webhooks are available to individual users and teams.

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.0-rc.11",
"version": "1.9.1-rc.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {

View File

@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -54,7 +54,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('name')}
>
{_(msg`Name`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'name' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
),
accessorKey: 'name',
@ -80,7 +88,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('signingVolume')}
>
{_(msg`Signing Volume`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
),
accessorKey: 'signingVolume',
@ -94,7 +110,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('createdAt')}
>
{_(msg`Created`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
);
},
@ -102,7 +126,7 @@ export const LeaderboardTable = ({
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder]);
}, [sortOrder, sortBy]);
useEffect(() => {
startTransition(() => {
@ -133,6 +157,9 @@ export const LeaderboardTable = ({
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
startTransition(() => {
updateSearchParams({
search: debouncedSearchString,
page,
perPage,
sortBy: column,
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
});

View File

@ -12,13 +12,14 @@ import {
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({
<Trans>Viewed</Trans>
</>
))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()}
</Badge>
)}

View File

@ -162,7 +162,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && (
{!team && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>

View File

@ -40,7 +40,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {

View File

@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DataTableActionDropdown } from '../data-table-action-dropdown';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<Button className="w-full" asChild>
<Link href={`${templateRootPath}/${template.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
@ -17,6 +17,8 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { MoveTemplateDialog } from './move-template-dialog';
@ -79,13 +81,24 @@ export const DataTableActionDropdown = ({
<Trans>Direct link</Trans>
</DropdownMenuItem>
{!teamId && (
{!teamId && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}

View File

@ -42,7 +42,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
router.refresh();

View File

@ -77,7 +77,11 @@ export const TemplateDirectLinkDialog = ({
);
const validDirectTemplateRecipients = useMemo(
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
() =>
template.recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
),
[template.recipients],
);

View File

@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({
};
return (
<>
<RecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
@ -186,7 +187,6 @@ export const SignDirectTemplateForm = ({
<SignatureField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -195,7 +195,6 @@ export const SignDirectTemplateForm = ({
<InitialsField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -204,7 +203,6 @@ export const SignDirectTemplateForm = ({
<NameField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -213,7 +211,6 @@ export const SignDirectTemplateForm = ({
<DateField
key={field.id}
field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField}
@ -224,7 +221,6 @@ export const SignDirectTemplateForm = ({
<EmailField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -241,7 +237,6 @@ export const SignDirectTemplateForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -259,7 +254,6 @@ export const SignDirectTemplateForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -277,7 +271,6 @@ export const SignDirectTemplateForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -295,7 +288,6 @@ export const SignDirectTemplateForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -313,7 +305,6 @@ export const SignDirectTemplateForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -383,6 +374,6 @@ export const SignDirectTemplateForm = ({
/>
</div>
</DocumentFlowFormContainerFooter>
</>
</RecipientProvider>
);
};

View File

@ -0,0 +1,73 @@
import { Trans } from '@lingui/macro';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
hasUninsertedFields: boolean;
isSubmitting: boolean;
};
export function AssistantConfirmationDialog({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
}: ConfirmationDialogProps) {
const onOpenChange = () => {
if (isSubmitting) {
return;
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone. Please
ensure that you have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<SigningDisclosure />
</div>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={hasUninsertedFields ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -13,7 +13,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
@ -27,23 +26,19 @@ import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const CheckboxField = ({
field,
recipient,
onSignField,
onUnsignField,
}: CheckboxFieldProps) => {
export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter();
const [isPending, startTransition] = useTransition();
@ -122,7 +117,9 @@ export const CheckboxField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -151,7 +148,7 @@ export const CheckboxField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -183,28 +180,25 @@ export const CheckboxField = ({
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (isLengthConditionMet) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: toCheckboxValue(checkedValues),
isBase64: true,
});
}
} else {
updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
}
await removeSignedFieldWithToken({
setCheckedValues(updatedValues);
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (updatedValues.length > 0) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: toCheckboxValue(updatedValues),
isBase64: true,
});
}
} catch (err) {
@ -216,7 +210,6 @@ export const CheckboxField = ({
variant: 'destructive',
});
} finally {
setCheckedValues(updatedValues);
startTransition(() => router.refresh());
}
};

View File

@ -16,20 +16,21 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -38,17 +39,17 @@ export type DateFieldProps = {
export const DateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField,
onUnsignField,
}: DateFieldProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
@ -59,12 +60,13 @@ export const DateField = ({
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = _(
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`,
);
@ -97,7 +99,9 @@ export const DateField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -123,7 +127,7 @@ export const DateField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -150,9 +154,21 @@ export const DateField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{localDateString}
</p>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{localDateString}
</p>
</div>
)}
</SigningFieldContainer>
);

View File

@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
@ -30,23 +29,19 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type DropdownFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DropdownField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DropdownFieldProps) => {
export const DropdownField = ({ field, onSignField, onUnsignField }: DropdownFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter();
const [isPending, startTransition] = useTransition();
@ -103,7 +98,9 @@ export const DropdownField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -134,7 +131,7 @@ export const DropdownField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}

View File

@ -11,32 +11,34 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client';
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type EmailFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
export const EmailField = ({ field, onSignField, onUnsignField }: EmailFieldProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { email: providedEmail } = useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const [isPending, startTransition] = useTransition();
@ -48,6 +50,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => {
@ -81,7 +86,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -107,7 +114,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -128,9 +135,21 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
</SigningFieldContainer>
);

View File

@ -1,19 +1,22 @@
'use client';
import { useMemo, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { type Field, FieldType, type Recipient, RecipientRole } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@ -21,8 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AssistantConfirmationDialog } from './assistant/assistant-confirmation-dialog';
import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog';
@ -32,6 +38,8 @@ export type SigningFormProps = {
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
};
export const SigningForm = ({
@ -40,19 +48,35 @@ export const SigningForm = ({
fields,
redirectUrl,
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
}: SigningFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time.
@ -67,7 +91,11 @@ export const SigningForm = ({
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
}, [fields]);
}, [fieldsRequiringValidation]);
const uninsertedRecipientFields = useMemo(() => {
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
@ -88,12 +116,31 @@ export const SigningForm = ({
}
await completeDocument();
};
// Reauth is currently not required for completing the document.
// await executeActionAuthProcedure({
// onReauthFormSubmit: completeDocument,
// actionTarget: 'DOCUMENT',
// });
const onAssistantFormSubmit = () => {
if (uninsertedRecipientFields.length > 0) {
return;
}
setIsConfirmationDialogOpen(true);
};
const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true);
try {
await completeDocument();
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
setIsAssistantSubmitting(false);
setIsConfirmationDialogOpen(false);
}
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
@ -113,7 +160,7 @@ export const SigningForm = ({
};
return (
<form
<div
className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{
@ -121,7 +168,6 @@ export const SigningForm = ({
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
},
)}
onSubmit={handleSubmit(onFormSubmit)}
>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
@ -129,17 +175,13 @@ export const SigningForm = ({
</FieldToolTip>
)}
<fieldset
disabled={isSubmitting}
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div className={cn('flex flex-1 flex-col')}>
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
<div className="flex flex-1 flex-col">
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
@ -176,91 +218,185 @@ export const SigningForm = ({
</div>
</div>
</>
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Complete the fields for the following signers. Once reviewed, they will inform
you if any modifications are needed.
</Trans>
</p>
<hr className="border-border my-4" />
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller
name="selectedSignerId"
control={assistantForm.control}
rules={{ required: 'Please select a signer' }}
render={({ field }) => (
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={field.value?.toString()}
onValueChange={(value) => {
field.onChange(value);
setSelectedSignerId?.(Number(value));
}}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
)}
/>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="submit"
className="w-full"
size="lg"
loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
>
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
</Button>
</div>
<AssistantConfirmationDialog
hasUninsertedFields={uninsertedFields.length > 0}
isOpen={isConfirmationDialogOpen}
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
/>
</form>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans>
</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
<Trans>Cancel</Trans>
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
<Trans>Cancel</Trans>
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</div>
</fieldset>
</form>
</>
)}
</div>
</fieldset>
</form>
</div>
</div>
);
};

View File

@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
@ -22,26 +21,22 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type InitialsFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const InitialsField = ({
field,
recipient,
onSignField,
onUnsignField,
}: InitialsFieldProps) => {
export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const { fullName } = useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const initials = extractInitials(fullName);
const [isPending, startTransition] = useTransition();
@ -87,7 +82,9 @@ export const InitialsField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}

View File

@ -11,13 +11,14 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -26,16 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
export const NameField = ({ field, onSignField, onUnsignField }: NameFieldProps) => {
const router = useRouter();
const { _ } = useLingui();
@ -43,6 +44,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
@ -56,13 +58,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => {
if (!providedFullName) {
if (!providedFullName && !isAssistantMode) {
setShowFullNameModal(true);
return false;
}
@ -85,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
try {
const value = name || providedFullName;
const value = name || providedFullName || '';
if (!value) {
if (!value && !isAssistantMode) {
setShowFullNameModal(true);
return;
}
@ -119,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -145,7 +152,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -172,9 +179,21 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>

View File

@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
@ -39,21 +39,32 @@ type ValidationErrors = {
export type NumberFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false);
const [showNumberModal, setShowNumberModal] = useState(false);
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
@ -71,16 +82,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setLocalNumber(text);
@ -104,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
};
const onDialogSignClick = () => {
setShowRadioModal(false);
setShowNumberModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -147,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
};
const onPreSign = () => {
setShowRadioModal(true);
if (isAssistantMode) {
return true;
}
setShowNumberModal(true);
if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
@ -172,8 +179,14 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const onRemove = async () => {
try {
if (isAssistantMode && !targetSigner) {
return;
}
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
token: signingRecipient.token,
fieldId: field.id,
};
@ -192,23 +205,23 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!showRadioModal) {
if (!showNumberModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors);
}
}, [showRadioModal]);
}, [showNumberModal]);
useEffect(() => {
if (
(!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue)
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -221,8 +234,8 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
if (parsedFieldMeta?.label) {
fieldDisplayName =
parsedFieldMeta.label.length > 10
? parsedFieldMeta.label.substring(0, 10) + '...'
parsedFieldMeta.label.length > 20
? parsedFieldMeta.label.substring(0, 20) + '...'
: parsedFieldMeta.label;
}
@ -234,7 +247,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
type="Number"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -260,12 +273,24 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
@ -321,7 +346,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowRadioModal(false);
setShowNumberModal(false);
setLocalNumber('');
}}
>

View File

@ -12,11 +12,12 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { DocumentAuthProvider } from './document-auth-provider';
import { NoLongerAvailable } from './no-longer-available';
@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient, completedFields] = await Promise.all([
const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }),
]);
@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({
token,
})
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
user={user}
>
<SigningPageView
recipient={recipient}
recipient={recipientWithFields}
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
/>
</DocumentAuthProvider>
</SigningProvider>

View File

@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
export type RadioFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter();
const [isPending, startTransition] = useTransition();
@ -68,16 +68,26 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (isAssistantMode && !targetSigner) {
return;
}
if (!selectedOption) {
return;
}
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
token: signingRecipient.token,
fieldId: field.id,
value: selectedOption,
isBase64: true,
authOptions,
...(isAssistantMode && {
isAssistantPrefill: true,
assistantId: recipient.id,
}),
};
if (onSignField) {
@ -99,7 +109,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -126,7 +138,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the selection.`),
variant: 'destructive',
});
}

View File

@ -0,0 +1,66 @@
'use client';
import { type PropsWithChildren, createContext, useContext } from 'react';
import type { Recipient } from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
export interface RecipientContextValue {
/**
* The recipient who is currently signing the document.
* In regular mode, this is the actual signer.
* In assistant mode, this is the recipient who is helping fill out the document.
*/
recipient: Recipient | RecipientWithFields;
/**
* Only present in assistant mode.
* The recipient on whose behalf we're filling out the document.
*/
targetSigner: RecipientWithFields | null;
/**
* Whether we're in assistant mode (one recipient filling out for another)
*/
isAssistantMode: boolean;
}
const RecipientContext = createContext<RecipientContextValue | null>(null);
export interface RecipientProviderProps extends PropsWithChildren {
recipient: Recipient | RecipientWithFields;
targetSigner?: RecipientWithFields | null;
}
export const RecipientProvider = ({
children,
recipient,
targetSigner = null,
}: RecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return (
<RecipientContext.Provider
value={{
recipient,
targetSigner,
isAssistantMode: !!targetSigner,
}}
>
{children}
</RecipientContext.Provider>
);
};
export function useRecipientContext() {
const context = useContext(RecipientContext);
if (!context) {
throw new Error('useRecipientContext must be used within a RecipientProvider');
}
return context;
}

View File

@ -11,7 +11,6 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
@ -28,12 +27,12 @@ import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean;
@ -41,15 +40,14 @@ export type SignatureFieldProps = {
export const SignatureField = ({
field,
recipient,
onSignField,
onUnsignField,
typedSignatureEnabled,
}: SignatureFieldProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { recipient } = useRecipientContext();
const signatureRef = useRef<HTMLParagraphElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

View File

@ -46,6 +46,7 @@ export type SignatureFieldProps = {
| 'Email'
| 'Name'
| 'Signature'
| 'Text'
| 'Radio'
| 'Dropdown'
| 'Number'

View File

@ -1,3 +1,7 @@
'use client';
import { useState } from 'react';
import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern';
@ -13,9 +17,10 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -32,16 +37,18 @@ import { InitialsField } from './initials-field';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
import { RecipientProvider } from './recipient-context';
import { RejectDocumentDialog } from './reject-document-dialog';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: Recipient;
recipient: RecipientWithFields;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
};
export const SigningPageView = ({
@ -50,9 +57,12 @@ export const SigningPageView = ({
fields,
completedFields,
isRecipientsTurn,
allRecipients = [],
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
@ -64,153 +74,162 @@ export const SigningPageView = ({
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
}
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
return (
<div className="mx-auto w-full max-w-screen-xl">
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div className="max-w-[50ch]">
<span className="text-muted-foreground truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>has invited you to approve this document</Trans>
),
)
.otherwise(() => null)}
</span>
</div>
<RejectDocumentDialog document={document} token={recipient.token} />
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="mx-auto w-full max-w-screen-xl">
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
{document.title}
</h1>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
/>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div className="max-w-[50ch]">
<span className="text-muted-foreground truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>has invited you to approve this document</Trans>
),
)
.with(RecipientRole.ASSISTANT, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)}
</span>
</div>
<RejectDocumentDialog document={document} token={recipient.token} />
</div>
</div>
<DocumentReadOnlyFields fields={completedFields} />
<AutoSign recipient={recipient} fields={fields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField
key={field.id}
field={field}
recipient={recipient}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.otherwise(() => null),
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
/>
</div>
</div>
<DocumentReadOnlyFields fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && (
<AutoSign recipient={recipient} fields={fields} />
)}
</ElementVisible>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields
.filter(
(field) =>
recipient.role !== RecipientRole.ASSISTANT ||
field.recipientId === selectedSigner?.id,
)
.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => <SignatureField key={field.id} field={field} />)
.with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
.with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => <EmailField key={field.id} field={field} />)
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return <TextField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return <NumberField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return <RadioField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return <CheckboxField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return <DropdownField key={field.id} field={fieldWithMeta} />;
})
.otherwise(() => null),
)}
</ElementVisible>
</div>
</RecipientProvider>
);
};

View File

@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
required: string[];
characterLimit: string[];
};
export type TextFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
export const TextField = ({ field, onSignField, onUnsignField }: TextFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter();
const initialErrors: Record<string, string[]> = {
const initialErrors: ValidationErrors = {
required: [],
characterLimit: [],
};
const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@ -62,7 +66,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
@ -165,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -193,7 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the text.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -233,7 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
type="Text"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -261,11 +268,23 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}
</p>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</p>
</div>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@ -281,6 +300,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
onChange={handleTextChange}

View File

@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
<Trans>Document Completed!</Trans>
</h3>

View File

@ -485,7 +485,6 @@ export const EmbedDirectTemplateClientPage = ({
{/* Fields */}
<EmbedDocumentFields
recipient={recipient}
fields={localFields}
metadata={metadata}
onSignField={onSignField}

View File

@ -13,6 +13,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall';
@ -96,16 +97,18 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient}
user={user}
>
<EmbedDirectTemplateClientPage
token={token}
updatedAt={template.updatedAt}
documentData={template.templateDocumentData}
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/>
<RecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage
token={token}
updatedAt={template.updatedAt}
documentData={template.templateDocumentData}
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/>
</RecipientProvider>
</DocumentAuthProvider>
</SigningProvider>
);

View File

@ -12,7 +12,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type Field, FieldType } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type {
@ -33,7 +33,6 @@ import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
export type EmbedDocumentFieldsProps = {
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -41,7 +40,6 @@ export type EmbedDocumentFieldsProps = {
};
export const EmbedDocumentFields = ({
recipient,
fields,
metadata,
onSignField,
@ -55,7 +53,6 @@ export const EmbedDocumentFields = ({
<SignatureField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@ -65,7 +62,6 @@ export const EmbedDocumentFields = ({
<InitialsField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -74,7 +70,6 @@ export const EmbedDocumentFields = ({
<NameField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -83,7 +78,6 @@ export const EmbedDocumentFields = ({
<DateField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
@ -94,7 +88,6 @@ export const EmbedDocumentFields = ({
<EmailField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -109,7 +102,6 @@ export const EmbedDocumentFields = ({
<TextField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -125,7 +117,6 @@ export const EmbedDocumentFields = ({
<NumberField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -141,7 +132,6 @@ export const EmbedDocumentFields = ({
<RadioField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -157,7 +147,6 @@ export const EmbedDocumentFields = ({
<CheckboxField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -173,7 +162,6 @@ export const EmbedDocumentFields = ({
<DropdownField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useEffect, useId, useLayoutEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
@ -9,8 +9,9 @@ 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 { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@ -19,10 +20,12 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
@ -35,12 +38,13 @@ export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
documentData: DocumentData;
recipient: Recipient;
recipient: RecipientWithFields;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentClientPage = ({
@ -53,6 +57,7 @@ export const EmbedSignDocumentClientPage = ({
isCompleted,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -70,17 +75,21 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter((field) => !field.inserted),
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
fields.filter((field) => field.inserted),
];
@ -89,6 +98,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId();
const onNextFieldClick = () => {
validateFieldsInserted(fields);
@ -214,164 +225,234 @@ export const EmbedSignDocumentClientPage = ({
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<LazyPDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<Trans>Sign document</Trans>
</h3>
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
{isAssistantMode ? (
<Trans>Assist with signing</Trans>
) : (
<Trans>Sign document</Trans>
)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
</CardContent>
</Card>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
{isAssistantMode ? (
<Trans>Help complete the document for other signers.</Trans>
) : (
<Trans>Sign the document to complete the process.</Trans>
)}
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
{isAssistantMode && (
<div>
<Label>
<Trans>Signing for</Trans>
</Label>
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={selectedSignerId?.toString()}
onValueChange={(value) => setSelectedSignerId(Number(value))}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
</fieldset>
</div>
)}
{!isAssistantMode && (
<>
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-start-2"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-start-2"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
</RecipientProvider>
);
};

View File

@ -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 <EmbedWaitingForTurn />;
}
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}
/>
</DocumentAuthProvider>
</SigningProvider>

View File

@ -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 (
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-center text-2xl font-bold">
<Trans>Waiting for Your Turn</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. Please check back soon as this document should be
available for you to sign shortly.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};

View File

@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,

View File

@ -67,7 +67,7 @@ export const TransferTeamDialog = ({
const {
data,
refetch: refetchTeamMembers,
isLoading: loadingTeamMembers,
isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,

View File

@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field prefilled',
value: formatGenericText(data.field.type),
},
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (

View File

@ -0,0 +1,275 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
sendImmediately: z.boolean().default(false),
});
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
trigger?: React.ReactNode;
onSuccess?: () => void;
};
export const TemplateBulkSendDialog = ({
templateId,
recipients,
trigger,
onSuccess,
}: TemplateBulkSendDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
sendImmediately: false,
},
});
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [
`recipient_${index + 1}_email`,
`recipient_${index + 1}_name`,
]);
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: 'template.csv',
});
a.click();
window.URL.revokeObjectURL(url);
};
const onSubmit = async (values: TBulkSendFormSchema) => {
try {
const csv = await values.file.text();
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
toast({
title: _(msg`Success`),
description: _(
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
),
});
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Bulk Send Template via CSV</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Upload a CSV file to create multiple documents from this template. Each row represents
one document with its recipient details.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="bg-muted/70 rounded-lg border p-4">
<h3 className="text-sm font-medium">
<Trans>CSV Structure</Trans>
</h3>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
</Trans>
</p>
<p className="mt-4 text-sm">
<Trans>Current recipients:</Trans>
</p>
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-y-2">
<Button onClick={onDownloadTemplate} variant="outline" type="button">
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-muted-foreground text-xs">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormItem>
<FormControl>
{!value ? (
<Button asChild variant="outline" className="w-full">
<label className="cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
disabled={form.formState.isSubmitting}
/>
<Upload className="mr-2 h-4 w-4" />
<Trans>Upload CSV</Trans>
</label>
</Button>
) : (
<div className="flex h-10 items-center rounded-md border px-3">
<div className="flex flex-1 items-center gap-2">
<FileIcon className="text-muted-foreground h-4 w-4" />
<span className="flex-1 truncate text-sm">{value.name}</span>
</div>
<Button
type="button"
variant="link"
className="text-destructive hover:text-destructive p-0 text-xs"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">
<Trans>Remove</Trans>
</span>
</Button>
</div>
)}
</FormControl>
{error && <p className="text-destructive text-sm">{error.message}</p>}
<p className="text-muted-foreground text-xs">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
</Trans>
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendImmediately"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="send-immediately"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="send-immediately"
className="text-muted-foreground ml-2 flex items-center text-sm"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Upload and Process</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

13
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.9.0-rc.11",
"version": "1.9.1-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.9.0-rc.11",
"version": "1.9.1-rc.0",
"workspaces": [
"apps/*",
"packages/*"
@ -106,7 +106,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.9.0-rc.11",
"version": "1.9.1-rc.0",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",
@ -13836,6 +13836,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
@ -35031,6 +35037,7 @@
"@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.0-rc.11",
"version": "1.9.1-rc.0",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",

View File

@ -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}`);
}

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { RecipientRole } from '@documenso/prisma/client';
@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({
const rejectDocumentLink = useMemo(() => {
const url = new URL(signDocumentLink);
url.searchParams.set('reject', 'true');
return url.toString();
}, []);
}, [signDocumentLink]);
return (
<>
@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{selfSigner ? (
<Trans>
Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
) : isTeamInvite ? (
<>
{includeSenderDetails ? (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
</Trans>
) : (
<Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()}
</Trans>
)}
<br />"{documentName}"
</>
) : (
<Trans>
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
)}
{match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, teamName: P.string }, () => (
<Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))
.otherwise(() => (
<Trans>
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
@ -85,6 +84,9 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => (
<Trans>Continue by assisting with the document.</Trans>
))
.exhaustive()}
</Text>
@ -105,6 +107,7 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()}
</Button>
</Section>

View File

@ -0,0 +1,91 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
export interface BulkSendCompleteEmailProps {
userName: string;
templateName: string;
totalProcessed: number;
successCount: number;
failedCount: number;
errors: string[];
assetBaseUrl?: string;
}
export const BulkSendCompleteEmail = ({
userName,
templateName,
totalProcessed,
successCount,
failedCount,
errors,
}: BulkSendCompleteEmailProps) => {
const { _ } = useLingui();
return (
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
</Text>
<Text className="text-sm">
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
</Text>
<Text className="text-lg font-semibold">
<Trans>Summary:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
<li>
<Trans>Total rows processed: {totalProcessed}</Trans>
</li>
<li className="mt-1">
<Trans>Successfully created: {successCount}</Trans>
</li>
<li className="mt-1">
<Trans>Failed: {failedCount}</Trans>
</li>
</ul>
{failedCount > 0 && (
<Section className="mt-4">
<Text className="text-lg font-semibold">
<Trans>The following errors occurred:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
{error}
</li>
))}
</ul>
</Section>
)}
<Text className="text-sm">
<Trans>
You can view the created documents in your dashboard under the "Documents created
from template" section.
</Trans>
</Text>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};

View File

@ -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',
},

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es'] as const;
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@ -46,6 +46,14 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
full: 'Spanish',
short: 'es',
},
it: {
full: 'Italian',
short: 'it',
},
pl: {
short: 'pl',
full: 'Polish',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>

View File

@ -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<keyof typeof RecipientRole, unknown>;
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<keyof typeof RecipientRole, MessageDescriptor>;

View File

@ -6,6 +6,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
/**
@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;

View File

@ -0,0 +1,39 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email';
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
templateId: z.number(),
templateName: z.string(),
totalProcessed: z.number(),
successCount: z.number(),
failedCount: z.number(),
errors: z.array(z.string()),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendBulkCompleteEmailJobDefinition = z.infer<
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = {
id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Bulk Complete Email',
version: '1.0.0',
trigger: {
name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-bulk-complete-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
TSendBulkCompleteEmailJobDefinition
>;

View File

@ -0,0 +1,208 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { prisma } from '@documenso/prisma';
import type { TeamGlobalSettings } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { AppError } from '../../../errors/app-error';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({
payload,
io,
}: {
payload: TBulkSendTemplateJobDefinition;
io: JobRunIO;
}) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
const template = await getTemplateById({
id: templateId,
userId,
teamId,
});
if (!template) {
throw new Error('Template not found');
}
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');
}
const { recipients } = template;
// Validate CSV structure
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
for (const header of requiredHeaders) {
if (!csvHeaders.includes(header)) {
throw new Error(`Missing required column: ${header}`);
}
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
email: true,
name: true,
},
});
const results = {
success: 0,
failed: 0,
errors: Array<string>(),
};
// Process each row
for (const [rowIndex, row] of rows.entries()) {
try {
for (const [recipientIndex] of recipients.entries()) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
throw new Error(
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
);
}
}
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
templateId: template.id,
userId,
teamId,
recipients: recipients.map((recipient, index) => {
return {
id: recipient.id,
email: row[`recipient_${index + 1}_email`] || recipient.email,
name: row[`recipient_${index + 1}_name`] || recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
};
}),
requestMetadata: {
source: 'app',
auth: 'session',
requestMetadata: requestMetadata || {},
},
});
});
if (sendImmediately) {
await io.runTask(`send-document-${rowIndex}`, async () => {
await sendDocument({
documentId: document.id,
userId,
teamId,
requestMetadata: {
source: 'app',
auth: 'session',
requestMetadata: requestMetadata || {},
},
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
});
}
results.success += 1;
} catch (error) {
results.failed += 1;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`);
}
}
await io.runTask('send-completion-email', async () => {
const completionTemplate = createElement(BulkSendCompleteEmail, {
userName: user.name || user.email,
templateName: template.title,
totalProcessed: rows.length,
successCount: results.success,
failedCount: results.failed,
errors: results.errors,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
let teamGlobalSettings: TeamGlobalSettings | undefined | null;
if (template.teamId) {
teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
where: {
teamId: template.teamId,
},
});
}
const branding = teamGlobalSettings
? teamGlobalSettingsToBranding(teamGlobalSettings)
: undefined;
const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: user.name || '',
address: user.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
html,
text,
});
});
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
teamId: z.number().optional(),
templateId: z.number(),
csvContent: z.string(),
sendImmediately: z.boolean(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TBulkSendTemplateJobDefinition = z.infer<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
>;
export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
name: 'Bulk Send Template',
version: '1.0.0',
trigger: {
name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./bulk-send-template.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
TBulkSendTemplateJobDefinition
>;

View File

@ -40,6 +40,7 @@
"@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus, Prisma } from '@documenso/prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export type SigningVolume = {
id: number;
@ -24,92 +24,78 @@ export async function getSigningVolume({
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) {
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
status: 'ACTIVE',
OR: [
{
user: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
},
},
{
team: {
name: { contains: search, mode: 'insensitive' },
},
},
],
});
const offset = Math.max(page - 1, 0) * perPage;
const [subscriptions, totalCount] = await Promise.all([
prisma.subscription.findMany({
where: whereClause,
include: {
user: {
select: {
name: true,
email: true,
documents: {
where: {
status: DocumentStatus.COMPLETED,
deletedAt: null,
teamId: null,
},
},
},
},
team: {
select: {
name: true,
documents: {
where: {
status: DocumentStatus.COMPLETED,
deletedAt: null,
},
},
},
},
},
orderBy:
sortBy === 'name'
? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }]
: sortBy === 'createdAt'
? [{ createdAt: sortOrder }]
: undefined,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
prisma.subscription.count({
where: whereClause,
}),
]);
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
const name =
subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown';
const userSignedDocs = subscription.user?.documents?.length || 0;
const teamSignedDocs = subscription.team?.documents?.length || 0;
return {
id: subscription.id,
name,
signingVolume: userSignedDocs + teamSignedDocs,
createdAt: subscription.createdAt,
planId: subscription.planId,
};
});
if (sortBy === 'signingVolume') {
leaderboardWithVolume.sort((a, b) => {
return sortOrder === 'desc'
? b.signingVolume - a.signingVolume
: a.signingVolume - b.signingVolume;
});
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: leaderboardWithVolume,
totalPages: Math.ceil(totalCount / perPage),
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}

View File

@ -15,7 +15,7 @@ import type {
TeamGlobalSettings,
User,
} from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -23,10 +23,15 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
id: number;
@ -112,6 +117,13 @@ export const deleteDocument = async ({
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
// Return partial document for API v1 response.
return {
id: document.id,

View File

@ -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';

View File

@ -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,

View File

@ -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,
},
}),
});
}
});
};

View File

@ -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: {

View File

@ -1,7 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@ -36,6 +36,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
);
const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit);
@ -83,6 +86,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined,
@ -278,6 +310,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
@ -293,7 +326,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system

View File

@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
where: {
token,
},
include: {
fields: true,
},
});
};

View File

@ -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;
};

View File

@ -95,7 +95,7 @@ export const createTeam = async ({
});
}
await tx.team.create({
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
@ -104,13 +104,23 @@ export const createTeam = async ({
members: {
create: [
{
userId,
userId: user.id,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
});
return {
@ -225,6 +235,16 @@ export const createTeamFromPendingTeam = async ({
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -11,9 +11,14 @@ export const ZBaseFieldMeta = z.object({
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']);
export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('initials'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
@ -21,6 +26,7 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('name'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
@ -28,6 +34,7 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('email'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
@ -35,6 +42,7 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('date'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
@ -44,6 +52,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
text: z.string().optional(),
characterLimit: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
@ -55,6 +64,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
minValue: z.number().optional(),
maxValue: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;

View File

@ -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`,

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED';

View File

@ -175,6 +175,7 @@ enum WebhookTriggerEvents {
DOCUMENT_SIGNED
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
}
model Webhook {
@ -416,6 +417,7 @@ enum RecipientRole {
SIGNER
VIEWER
APPROVER
ASSISTANT
}
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])

View File

@ -0,0 +1,5 @@
import type { Field, Recipient } from '@documenso/prisma/client';
export type RecipientWithFields = Recipient & {
fields: Field[];
};

View File

@ -1,5 +1,8 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import {
@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import {
ZBulkSendTemplateMutationSchema,
ZCreateDocumentFromDirectTemplateRequestSchema,
ZCreateDocumentFromTemplateRequestSchema,
ZCreateDocumentFromTemplateResponseSchema,
@ -414,4 +418,48 @@ export const templateRouter = router({
userId,
});
}),
/**
* @private
*/
uploadBulkSend: authenticatedProcedure
.input(ZBulkSendTemplateMutationSchema)
.mutation(async ({ ctx, input }) => {
const { templateId, teamId, csv, sendImmediately } = input;
const { user } = ctx;
if (csv.length > 4 * 1024 * 1024) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'File size exceeds 4MB limit',
});
}
const template = await getTemplateById({
id: templateId,
teamId,
userId: user.id,
});
if (!template) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Template not found',
});
}
await jobs.triggerJob({
name: 'internal.bulk-send-template',
payload: {
userId: user.id,
teamId,
templateId,
csvContent: csv,
sendImmediately,
requestMetadata: ctx.metadata.requestMetadata,
},
});
return { success: true };
}),
});

View File

@ -188,6 +188,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
export const ZBulkSendTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
csv: z.string().min(1),
sendImmediately: z.boolean(),
});
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
export type TBulkSendTemplateMutationSchema = z.infer<typeof ZBulkSendTemplateMutationSchema>;

View File

@ -31,7 +31,8 @@ const getCardClassNames = (
checkBoxOrRadio: boolean,
cardClassName?: string,
) => {
const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all';
const baseClasses =
'field--FieldRootContainer field-card-container relative z-20 h-full w-full transition-all';
const insertedClasses =
'bg-primary/20 border-primary ring-primary/20 ring-offset-primary/20 ring-2 ring-offset-2 dark:shadow-none';
@ -141,6 +142,7 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
<Card
id={`field-${field.id}`}
ref={ref}
data-field-type={field.type}
data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames}
>

View File

@ -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<HTMLButtonElement, RecipientRoleSelectProps>(
({ hideCCRecipients, ...props }, ref) => (
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
@ -110,6 +113,42 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</div>
</SelectItem>
)}
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
),

View File

@ -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 && (
<span className="gradie flex-1 truncate text-left">
{selectedSigner?.email}
</span>
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />

View File

@ -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 (
<>
<DocumentFlowFormContainerHeader
@ -384,11 +381,16 @@ export const AddSignersFormPartial = ({
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
);
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) =>
// 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 = ({
)}
</Form>
</AnimateGenericFadeInOut>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>

View File

@ -59,10 +59,10 @@ export const FieldIcon = ({
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
label =
fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
fieldMeta.text.length > 20 ? fieldMeta.text.substring(0, 20) + '...' : fieldMeta.text;
} else if (fieldMeta.label) {
label =
fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
fieldMeta.label.length > 20 ? fieldMeta.label.substring(0, 20) + '...' : fieldMeta.label;
} else {
label = fieldIcons[type]?.label;
}

View File

@ -71,21 +71,25 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
return {
type: 'initials',
fontSize: 14,
textAlign: 'left',
};
case FieldType.NAME:
return {
type: 'name',
fontSize: 14,
textAlign: 'left',
};
case FieldType.EMAIL:
return {
type: 'email',
fontSize: 14,
textAlign: 'left',
};
case FieldType.DATE:
return {
type: 'date',
fontSize: 14,
textAlign: 'left',
};
case FieldType.TEXT:
return {
@ -97,6 +101,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
fontSize: 14,
required: false,
readOnly: false,
textAlign: 'left',
};
case FieldType.NUMBER:
return {
@ -110,6 +115,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
required: false,
readOnly: false,
fontSize: 14,
textAlign: 'left',
};
case FieldType.RADIO:
return {

View File

@ -5,6 +5,13 @@ import { validateFields as validateDateFields } from '@documenso/lib/advanced-fi
import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateFieldAdvancedSettingsProps = {
fieldState: DateFieldMeta;
@ -66,6 +73,27 @@ export const DateFieldAdvancedSettings = ({
max={96}
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@ -5,6 +5,13 @@ import { validateFields as validateEmailFields } from '@documenso/lib/advanced-f
import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type EmailFieldAdvancedSettingsProps = {
fieldState: EmailFieldMeta;
@ -48,6 +55,27 @@ export const EmailFieldAdvancedSettings = ({
max={96}
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@ -6,6 +6,8 @@ import { type TInitialsFieldMeta as InitialsFieldMeta } from '@documenso/lib/typ
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../select';
type InitialsFieldAdvancedSettingsProps = {
fieldState: InitialsFieldMeta;
handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void;
@ -48,6 +50,27 @@ export const InitialsFieldAdvancedSettings = ({
max={96}
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@ -5,6 +5,13 @@ import { validateFields as validateNameFields } from '@documenso/lib/advanced-fi
import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type NameFieldAdvancedSettingsProps = {
fieldState: NameFieldMeta;
@ -48,6 +55,27 @@ export const NameFieldAdvancedSettings = ({
max={96}
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@ -38,12 +38,12 @@ export const NumberFieldAdvancedSettings = ({
const [showValidation, setShowValidation] = useState(false);
const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => {
const userValue = field === 'value' ? value : fieldState.value ?? 0;
const userValue = field === 'value' ? value : (fieldState.value ?? 0);
const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue ?? 0);
const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue ?? 0);
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat ?? '';
const numberFormat = field === 'numberFormat' ? String(value) : (fieldState.numberFormat ?? '');
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
const valueErrors = validateNumberField(String(userValue), {
@ -135,6 +135,27 @@ export const NumberFieldAdvancedSettings = ({
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-2 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch

View File

@ -5,6 +5,13 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
@ -22,7 +29,7 @@ export const TextFieldAdvancedSettings = ({
const { _ } = useLingui();
const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => {
const text = field === 'text' ? String(value) : fieldState.text ?? '';
const text = field === 'text' ? String(value) : (fieldState.text ?? '');
const limit =
field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit ?? 0);
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
@ -112,6 +119,27 @@ export const TextFieldAdvancedSettings = ({
/>
</div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<Switch

View File

@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Warning</AlertDialogTitle>
<AlertDialogDescription>
You have an assistant role on the signers list, removing the signing order will change
the assistant role to signer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Proceed</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -19,18 +19,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children: _children, ...props }, ref) => {
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'border-input ring-offset-background focus:ring-ring h-4 w-4 rounded-full border focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="fill-primary text-primary h-2.5 w-2.5" />
<Circle className="fill-primary h-2.5 w-2.5" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);

View File

@ -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<RecipientRole, JSX.Element> = {
APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />,
ASSISTANT: <User className="h-4 w-4" />,
};

View File

@ -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]);

View File

@ -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 (
<>
<DocumentFlowFormContainerHeader
@ -353,11 +412,19 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onCheckedChange={(checked) => {
if (
!checked &&
watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT)
) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
);
}}
disabled={isSubmitting}
/>
</FormControl>
@ -556,7 +623,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
onValueChange={(value) =>
// 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()}
/>
</DocumentFlowFormContainerFooter>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
</>
);
};