Compare commits

...

46 Commits

Author SHA1 Message Date
00b46561c2 v1.9.1-rc.2 2025-02-20 11:35:03 +11:00
11bc93a9a4 feat: allow document rejection in embeds (#1662)
Bing bang
2025-02-20 11:34:19 +11:00
11528090a5 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:17:47 +11:00
3c4863f285 chore: add asssitant role to the docs (#1638) 2025-02-17 15:42:37 +11:00
2ff330f9d4 chore: update local seed data (#1622)
## Description

Add multiple example documents, pending documents, and templates for
both admin and example users

## Changes Made
- Added seeding of multiple example documents and templates for both
example and admin users

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.
2025-02-10 22:55:12 +11:00
ce1c93b2a6 v1.9.1-rc.1 2025-02-05 21:03:15 +11:00
82337e4e3a fix: typed signature not working (#1635)
The `typedSignatureEnabled` prop was removed from the `SignatureField`
component, which broke the typed signature meaning that nobody could
sign documents by typing their signature.
2025-02-05 21:02:21 +11:00
7d9a3f9776 fix: assistant mode breaks for number fields 2025-02-04 07:59:41 +11:00
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
9183f668d3 chore: bump node version for docker 2025-01-27 12:20:04 +11:00
54ea96391a fix: correct redirect after document duplication (#1595) 2025-01-23 16:34:22 +11:00
42d24fd1a1 feat: copy, paste, duplicate template fields (#1594) 2025-01-23 14:28:26 +11:00
dc36a8182c v1.9.0-rc.11 2025-01-21 09:49:22 +11:00
0ef85b47b1 fix: handle empty object as fieldMeta 2025-01-21 09:46:54 +11:00
058d9dd0ba v1.9.0-rc.10 2025-01-20 19:54:39 +11:00
74bb230247 fix: add empty success responses (#1600) 2025-01-20 19:47:39 +11:00
7c1e0f34e8 v1.9.0-rc.9 2025-01-20 16:08:15 +11:00
7e31323faa fix: add team context to more vanilla client usages 2025-01-20 15:53:28 +11:00
a28cdf437b feat: add get field endpoints (#1599) 2025-01-20 15:53:12 +11:00
80dfbeb16f feat: add angular embedding docs (#1592) 2025-01-20 09:35:47 +11:00
9de3a32ceb fix: pass team id to vanilla trpc client 2025-01-20 09:30:36 +11:00
0d3864548c fix: bump trpc and openapi packages (#1591) 2025-01-19 22:07:02 +11:00
9e03747e43 feat: add create document beta endpoint (#1584) 2025-01-16 13:36:00 +11:00
5750f2b477 feat: add prisma json types (#1583) 2025-01-15 13:46:45 +11:00
901be70f97 feat: add consistent response schemas (#1582) 2025-01-14 00:43:35 +11:00
7d0a9c6439 fix: refactor prisma relations (#1581) 2025-01-13 13:41:53 +11:00
48b55758e3 feat: ignore unrecognized fields from authorization response (#1479)
When authenticating using OIDC some IDPs send additional fields in their
authorization response. 

This leads to an error because these fields can't be persisted to the DB 
through the auth.js prisma adapter. 

This PR solves this by deleting all unrecognized fields from the 
authorization response before persisting. 

This behaviour is also compliant to 
[RFC6749 Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2)
2025-01-13 11:05:37 +11:00
334 changed files with 17760 additions and 7002 deletions

View File

@ -10,7 +10,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
actions: read actions: read
contents: read contents: read

View File

@ -8,7 +8,7 @@ jobs:
e2e_tests: e2e_tests:
name: 'E2E Tests' name: 'E2E Tests'
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-22.04 runs-on: warp-ubuntu-2204-x64-16x
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@ -27,6 +27,6 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"typescript": "^5" "typescript": "5.6.2"
} }
} }

View File

@ -14,4 +14,4 @@
"public-api": "Public API", "public-api": "Public API",
"embedding": "Embedding", "embedding": "Embedding",
"webhooks": "Webhooks" "webhooks": "Webhooks"
} }

View File

@ -5,5 +5,6 @@
"svelte": "Svelte Integration", "svelte": "Svelte Integration",
"solid": "Solid Integration", "solid": "Solid Integration",
"preact": "Preact Integration", "preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables" "css-variables": "CSS Variables"
} }

View File

@ -0,0 +1,90 @@
---
title: Angular Integration
description: Learn how to use our embedding SDK within your Angular application.
---
# Angular Integration
Our Angular SDK provides a simple way to embed a signing experience within your Angular application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-angular
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```typescript
import { Component } from '@angular/core';
import { EmbedDirectTemplate } from '@documenso/embed-angular';
@Component({
selector: 'app-embedding',
template: `
<embed-direct-template [token]="token" />
`,
standalone: true,
imports: [EmbedDirectTemplate],
})
export class EmbeddingComponent {
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
}
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```typescript
import { Component } from '@angular/core';
import { EmbedSignDocument } from '@documenso/embed-angular';
@Component({
selector: 'app-embedding',
template: `
<embed-sign-document [token]="token" />
`,
standalone: true,
imports: [EmbedSignDocument],
})
export class EmbeddingComponent {
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
}
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

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. 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 ## Related
- [React Integration](/developers/embedding/react) - [React Integration](/developers/embedding/react)

View File

@ -5,7 +5,7 @@ description: Learn how to use embedding to bring signing to your own website or
# Embedding # Embedding
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, or using generalized web components, this guide will help you get started with embedding Documenso. Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso.
## Availability ## Availability
@ -73,13 +73,14 @@ These customization options are available for both Direct Templates and Signing
We support embedding across a range of popular JavaScript frameworks, including: We support embedding across a range of popular JavaScript frameworks, including:
| Framework | Package | | Framework | Package |
| --------- | -------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) | | React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) | | Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) | | Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) | | Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) | | Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested. Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
@ -127,7 +128,7 @@ This will show a dialog which will ask you to configure which recipient should b
## Embedding with Signing Tokens ## Embedding with Signing Tokens
To embed the signing process for an ordinary document, youll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely. To embed the signing process for an ordinary document, you'll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
#### Instructions #### Instructions
@ -164,6 +165,7 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
- [Vue](/developers/embedding/vue) - [Vue](/developers/embedding/vue)
- [Svelte](/developers/embedding/svelte) - [Svelte](/developers/embedding/svelte)
- [Solid](/developers/embedding/solid) - [Solid](/developers/embedding/solid)
- [Angular](/developers/embedding/angular)
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases. If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
@ -174,4 +176,5 @@ If you're using **web components**, the integration process is slightly differen
- [Svelte Integration](/developers/embedding/svelte) - [Svelte Integration](/developers/embedding/svelte)
- [Solid Integration](/developers/embedding/solid) - [Solid Integration](/developers/embedding/solid)
- [Preact Integration](/developers/embedding/preact) - [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables) - [CSS Variables](/developers/embedding/css-variables)

View File

@ -3,6 +3,8 @@ title: Public API
description: Learn how to interact with your documents programmatically using the Documenso public API. description: Learn how to interact with your documents programmatically using the Documenso public API.
--- ---
import { Callout, Steps } from 'nextra/components';
# Public API # 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: 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. 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 ## 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.signed`
- `document.completed` - `document.completed`
- `document.rejected` - `document.rejected`
- `document.cancelled`
## Create a webhook subscription ## 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: To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload. - 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. - 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) ![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 ## Availability
Webhooks are available to individual users and teams. Webhooks are available to individual users and teams.

View File

@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions. Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature | | Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: | | :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes | | Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional | | Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No | | Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No | | Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields ### Fields

View File

@ -16,8 +16,8 @@
"next": "14.2.6" "next": "14.2.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.16.5", "@types/node": "^20",
"@types/react": "18.3.5", "@types/react": "18.3.5",
"typescript": "5.5.4" "typescript": "5.6.2"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.9.0-rc.8", "version": "1.9.1-rc.2",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -26,7 +26,6 @@
"@lingui/react": "^4.11.3", "@lingui/react": "^4.11.3",
"@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3", "@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"colord": "^2.9.3", "colord": "^2.9.3",
"cookie-es": "^1.0.0", "cookie-es": "^1.0.0",
"formidable": "^2.1.1", "formidable": "^2.1.1",
@ -55,7 +54,7 @@
"recharts": "^2.7.2", "recharts": "^2.7.2",
"remeda": "^2.17.3", "remeda": "^2.17.3",
"sharp": "0.32.6", "sharp": "0.32.6",
"trpc-openapi": "^1.2.0", "trpc-to-openapi": "2.0.4",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"uqr": "^0.1.2", "uqr": "^0.1.2",
@ -68,11 +67,11 @@
"@simplewebauthn/types": "^9.0.1", "@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6", "@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "^20",
"@types/papaparse": "^5.3.14", "@types/papaparse": "^5.3.14",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2" "typescript": "5.6.2"
} }
} }

View File

@ -28,7 +28,7 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } = const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({ trpc.admin.resealDocument.useMutation({
onSuccess: () => { onSuccess: () => {
toast({ toast({

View File

@ -59,7 +59,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<Trans>Admin Actions</Trans> <Trans>Admin Actions</Trans>
</h2> </h2>
<AdminActions className="mt-2" document={document} recipients={document.Recipient} /> <AdminActions className="mt-2" document={document} recipients={document.recipients} />
<hr className="my-4" /> <hr className="my-4" />
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
@ -68,7 +68,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<div className="mt-4"> <div className="mt-4">
<Accordion type="multiple" className="space-y-4"> <Accordion type="multiple" className="space-y-4">
{document.Recipient.map((recipient) => ( {document.recipients.map((recipient) => (
<AccordionItem <AccordionItem
key={recipient.id} key={recipient.id}
value={recipient.id.toString()} value={recipient.id.toString()}

View File

@ -39,9 +39,9 @@ type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormS
export type RecipientItemProps = { export type RecipientItemProps = {
recipient: Recipient & { recipient: Recipient & {
Field: Array< fields: Array<
Field & { Field & {
Signature: Signature | null; signature: Signature | null;
} }
>; >;
}; };
@ -89,13 +89,13 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
accessorKey: 'signature', accessorKey: 'signature',
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
{row.original.Signature?.typedSignature && ( {row.original.signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span> <span>{row.original.signature.typedSignature}</span>
)} )}
{row.original.Signature?.signatureImageAsBase64 && ( {row.original.signature?.signatureImageAsBase64 && (
<img <img
src={row.original.Signature.signatureImageAsBase64} src={row.original.signature.signatureImageAsBase64}
alt="Signature" alt="Signature"
className="h-12 w-full dark:invert" className="h-12 w-full dark:invert"
/> />
@ -103,7 +103,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
</div> </div>
), ),
}, },
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[]; ] satisfies DataTableColumnDef<(typeof recipient)['fields'][number]>[];
}, []); }, []);
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => { const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
@ -190,7 +190,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
<Trans>Fields</Trans> <Trans>Fields</Trans>
</h2> </h2>
<DataTable columns={columns} data={recipient.Field} /> <DataTable columns={columns} data={recipient.fields} />
</div> </div>
); );
}; };

View File

@ -8,7 +8,6 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import type { Document } from '@documenso/prisma/client'; import type { Document } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -36,7 +35,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } = const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
trpc.admin.deleteDocument.useMutation(); trpc.admin.deleteDocument.useMutation();
const handleDeleteDocument = async () => { const handleDeleteDocument = async () => {
@ -55,21 +54,12 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
router.push('/admin/documents'); router.push('/admin/documents');
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({
toast({ title: _(msg`An unknown error occurred`),
title: _(msg`An error occurred`), variant: 'destructive',
description: err.message, description:
variant: 'destructive', 'We encountered an unknown error while attempting to delete your document. Please try again later.',
}); });
} else {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
} }
}; };
@ -77,7 +67,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
<div> <div>
<div> <div>
<Alert <Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row " className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral" variant="neutral"
> >
<div> <div>

View File

@ -37,7 +37,7 @@ export const AdminDocumentResults = () => {
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined; const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isLoading: isFindDocumentsLoading } = const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery( trpc.admin.findDocuments.useQuery(
{ {
query: debouncedTerm, query: debouncedTerm,
@ -45,7 +45,7 @@ export const AdminDocumentResults = () => {
perPage: perPage || 20, perPage: perPage || 20,
}, },
{ {
keepPreviousData: true, placeholderData: (previousData) => previousData,
}, },
); );
@ -86,14 +86,14 @@ export const AdminDocumentResults = () => {
header: _(msg`Owner`), header: _(msg`Owner`),
accessorKey: 'owner', accessorKey: 'owner',
cell: ({ row }) => { cell: ({ row }) => {
const avatarFallbackText = row.original.User.name const avatarFallbackText = row.original.user.name
? extractInitials(row.original.User.name) ? extractInitials(row.original.user.name)
: row.original.User.email.slice(0, 1).toUpperCase(); : row.original.user.email.slice(0, 1).toUpperCase();
return ( return (
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger> <TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}> <Link href={`/admin/users/${row.original.user.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white"> <Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400"> <AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText} {avatarFallbackText}
@ -110,8 +110,8 @@ export const AdminDocumentResults = () => {
</Avatar> </Avatar>
<div className="text-muted-foreground flex flex-col text-sm"> <div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span> <span>{row.original.user.name}</span>
<span>{row.original.User.email}</span> <span>{row.original.user.email}</span>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

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

View File

@ -13,7 +13,6 @@ import {
SITE_SETTINGS_BANNER_ID, SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema, ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner'; } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker'; import { ColorPicker } from '@documenso/ui/primitives/color-picker';
@ -59,7 +58,7 @@ export function BannerForm({ banner }: BannerFormProps) {
const enabled = form.watch('enabled'); const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } = const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation(); trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => { const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
@ -78,21 +77,13 @@ export function BannerForm({ banner }: BannerFormProps) {
router.refresh(); router.refresh();
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({
toast({ title: _(msg`An unknown error occurred`),
title: _(msg`An error occurred`), variant: 'destructive',
description: err.message, description: _(
variant: 'destructive', msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
}); ),
} else { });
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
} }
}; };

View File

@ -6,9 +6,10 @@ import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -37,7 +38,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isLoading: isDeletingUser } = const { mutateAsync: deleteUser, isPending: isDeletingUser } =
trpc.admin.deleteUser.useMutation(); trpc.admin.deleteUser.useMutation();
const onDeleteAccount = async () => { const onDeleteAccount = async () => {
@ -54,23 +55,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
router.push('/admin/users'); router.push('/admin/users');
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { const error = AppError.parseError(err);
toast({
title: _(msg`An error occurred`), const errorMessage = match(error.code)
description: err.message, .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
variant: 'destructive', .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to delete this user.`)
}); .otherwise(() => msg`An error occurred while deleting the user.`);
} else {
toast({ toast({
title: _(msg`An unknown error occurred`), title: _(msg`Error`),
variant: 'destructive', description: _(errorMessage),
description: variant: 'destructive',
err.message ?? duration: 7500,
_( });
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
),
});
}
} }
}; };

View File

@ -34,7 +34,7 @@ export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialo
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const { mutateAsync: disableUser, isLoading: isDisablingUser } = const { mutateAsync: disableUser, isPending: isDisablingUser } =
trpc.admin.disableUser.useMutation(); trpc.admin.disableUser.useMutation();
const onDisableAccount = async () => { const onDisableAccount = async () => {

View File

@ -34,7 +34,7 @@ export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogPr
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const { mutateAsync: enableUser, isLoading: isEnablingUser } = const { mutateAsync: enableUser, isPending: isEnablingUser } =
trpc.admin.enableUser.useMutation(); trpc.admin.enableUser.useMutation();
const onEnableAccount = async () => { const onEnableAccount = async () => {

View File

@ -22,8 +22,8 @@ type UserData = {
name: string | null; name: string | null;
email: string; email: string;
roles: Role[]; roles: Role[];
Subscription?: SubscriptionLite[] | null; subscriptions?: SubscriptionLite[] | null;
Document: DocumentLite[]; documents: DocumentLite[];
}; };
type SubscriptionLite = Pick< type SubscriptionLite = Pick<
@ -81,7 +81,7 @@ export const UsersDataTable = ({
header: _(msg`Subscription`), header: _(msg`Subscription`),
accessorKey: 'subscription', accessorKey: 'subscription',
cell: ({ row }) => { cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) => const foundIndividualSubscription = (row.original.subscriptions ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId), individualPriceIds.includes(sub.priceId),
); );
@ -92,7 +92,7 @@ export const UsersDataTable = ({
header: _(msg`Documents`), header: _(msg`Documents`),
accessorKey: 'documents', accessorKey: 'documents',
cell: ({ row }) => { cell: ({ row }) => {
return <div>{row.original.Document.length}</div>; return <div>{row.original.documents?.length}</div>;
}, },
}, },
{ {

View File

@ -18,8 +18,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = { export type DocumentPageViewButtonProps = {
document: Document & { document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[]; recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'>;
@ -34,7 +34,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
return null; return null;
} }
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = document.recipients.find((recipient) => recipient.email === session.user.email);
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
@ -46,9 +46,16 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
const documentWithData = await trpcClient.document.getDocumentById.query({ const documentWithData = await trpcClient.document.getDocumentById.query(
documentId: document.id, {
}); documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData; const documentData = documentWithData?.documentData;

View File

@ -41,8 +41,8 @@ import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
export type DocumentPageViewDropdownProps = { export type DocumentPageViewDropdownProps = {
document: Document & { document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[]; recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null }; team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
@ -60,9 +60,9 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
return null; return null;
} }
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = document.recipients.find((recipient) => recipient.email === session.user.email);
const isOwner = document.User.id === session.user.id; const isOwner = document.user.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null; const isDeleted = document.deletedAt !== null;
@ -74,9 +74,16 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
const documentWithData = await trpcClient.document.getDocumentById.query({ const documentWithData = await trpcClient.document.getDocumentById.query(
documentId: document.id, {
}); documentId: document.id,
},
{
context: {
teamId: team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData; const documentData = documentWithData?.documentData;
@ -94,7 +101,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
} }
}; };
const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
<DropdownMenu> <DropdownMenu>
@ -149,7 +156,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
{canManageDocument && ( {canManageDocument && (
<DocumentRecipientLinkCopyDialog <DocumentRecipientLinkCopyDialog
recipients={document.Recipient} recipients={document.recipients}
trigger={ trigger={
<DropdownMenuItem <DropdownMenuItem
disabled={!isPending || isDeleted} disabled={!isPending || isDeleted}

View File

@ -12,8 +12,8 @@ import type { Document, Recipient, User } from '@documenso/prisma/client';
export type DocumentPageViewInformationProps = { export type DocumentPageViewInformationProps = {
userId: number; userId: number;
document: Document & { document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[]; recipients: Recipient[];
}; };
}; };
@ -29,7 +29,8 @@ export const DocumentPageViewInformation = ({
return [ return [
{ {
description: msg`Uploaded by`, description: msg`Uploaded by`,
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email, value:
userId === document.userId ? _(msg`You`) : (document.user.name ?? document.user.email),
}, },
{ {
description: msg`Created`, description: msg`Created`,

View File

@ -12,13 +12,14 @@ import {
MailOpenIcon, MailOpenIcon,
PenIcon, PenIcon,
PlusIcon, PlusIcon,
UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } 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 { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -28,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = { export type DocumentPageViewRecipientsProps = {
document: Document & { document: Document & {
Recipient: Recipient[]; recipients: Recipient[];
}; };
documentRootPath: string; documentRootPath: string;
}; };
@ -40,7 +41,7 @@ export const DocumentPageViewRecipients = ({
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const recipients = document.Recipient; const recipients = document.recipients;
return ( return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({
<Trans>Viewed</Trans> <Trans>Viewed</Trans>
</> </>
)) ))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()} .exhaustive()}
</Badge> </Badge>
)} )}

View File

@ -71,7 +71,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
const documentVisibility = document?.visibility; const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role; const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true; let canAccessDocument = true;
if (team && !isRecipient && document?.userId !== user.id) { if (team && !isRecipient && document?.userId !== user.id) {
@ -131,7 +131,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
const documentWithRecipients = { const documentWithRecipients = {
...document, ...document,
Recipient: recipients, recipients,
}; };
return ( return (

View File

@ -12,7 +12,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import type { TGetDocumentWithDetailsByIdResponse } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import type { TDocument } from '@documenso/lib/types/document';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -35,7 +35,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = { export type EditDocumentFormProps = {
className?: string; className?: string;
initialDocument: TGetDocumentWithDetailsByIdResponse; initialDocument: TDocument;
documentRootPath: string; documentRootPath: string;
isDocumentEnterprise: boolean; isDocumentEnterprise: boolean;
}; };
@ -71,7 +71,7 @@ export const EditDocumentForm = ({
}, },
); );
const { Recipient: recipients, Field: fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({ const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@ -105,7 +105,7 @@ export const EditDocumentForm = ({
{ {
documentId: initialDocument.id, documentId: initialDocument.id,
}, },
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }), (oldData) => ({ ...(oldData || initialDocument), fields: newFields }),
); );
}, },
}); });
@ -117,7 +117,7 @@ export const EditDocumentForm = ({
{ {
documentId: initialDocument.id, documentId: initialDocument.id,
}, },
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }), (oldData) => ({ ...(oldData || initialDocument), recipients: newRecipients }),
); );
}, },
}); });

View File

@ -52,7 +52,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const documentVisibility = document?.visibility; const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role; const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true; let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) { if (!isRecipient && document?.userId !== user.id) {
@ -78,7 +78,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
redirect(`${documentRootPath}/${documentId}`); redirect(`${documentRootPath}/${documentId}`);
} }
const { documentMeta, Recipient: recipients } = document; const { documentMeta, recipients } = document;
if (documentMeta?.password) { if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY; const key = DOCUMENSO_ENCRYPTION_KEY;

View File

@ -37,17 +37,16 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery(
trpc.document.findDocumentAuditLogs.useQuery( {
{ documentId,
documentId, page: parsedSearchParams.page,
page: parsedSearchParams.page, perPage: parsedSearchParams.perPage,
perPage: parsedSearchParams.perPage, },
}, {
{ placeholderData: (previousData) => previousData,
keepPreviousData: true, },
}, );
);
const onPaginationChange = (page: number, perPage: number) => { const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({ updateSearchParams({
@ -132,7 +131,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
enable: isLoadingError, enable: isLoadingError,
}} }}
skeleton={{ skeleton={{
enable: isLoading && isInitialLoading, enable: isLoading,
rows: 3, rows: 3,
component: ( component: (
<> <>

View File

@ -77,9 +77,9 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
}, },
{ {
description: msg`Created by`, description: msg`Created by`,
value: document.User.name value: document.user.name
? `${document.User.name} (${document.User.email})` ? `${document.user.name} (${document.user.email})`
: document.User.email, : document.user.email,
}, },
{ {
description: msg`Date created`, description: msg`Date created`,

View File

@ -19,7 +19,7 @@ export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditL
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isLoading } = const { mutateAsync: downloadAuditLogs, isPending } =
trpc.document.downloadAuditLogs.useMutation(); trpc.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => { const onDownloadAuditLogsClick = async () => {
@ -70,10 +70,10 @@ export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditL
return ( return (
<Button <Button
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isLoading} loading={isPending}
onClick={() => void onDownloadAuditLogsClick()} onClick={() => void onDownloadAuditLogsClick()}
> >
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
<Trans>Download Audit Logs</Trans> <Trans>Download Audit Logs</Trans>
</Button> </Button>
); );

View File

@ -26,7 +26,7 @@ export const DownloadCertificateButton = ({
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
const { mutateAsync: downloadCertificate, isLoading } = const { mutateAsync: downloadCertificate, isPending } =
trpc.document.downloadCertificate.useMutation(); trpc.document.downloadCertificate.useMutation();
const onDownloadCertificatesClick = async () => { const onDownloadCertificatesClick = async () => {
@ -77,12 +77,12 @@ export const DownloadCertificateButton = ({
return ( return (
<Button <Button
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isLoading} loading={isPending}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED} disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
<Trans>Download Certificate</Trans> <Trans>Download Certificate</Trans>
</Button> </Button>
); );

View File

@ -12,15 +12,14 @@ import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = { export type DataTableActionButtonProps = {
row: Document & { row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[]; recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'>;
@ -35,9 +34,9 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
return null; return null;
} }
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id; const isOwner = row.user.id === session.user.id;
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
@ -50,17 +49,20 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
let document: DocumentWithData | null = null; const document = !recipient
? await trpcClient.document.getDocumentById.query(
if (!recipient) { {
document = await trpcClient.document.getDocumentById.query({ documentId: row.id,
documentId: row.id, },
}); {
} else { context: {
document = await trpcClient.document.getDocumentByToken.query({ teamId: team?.id?.toString(),
token: recipient.token, },
}); },
} )
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData; const documentData = document?.documentData;

View File

@ -23,9 +23,8 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@ -46,8 +45,8 @@ import { MoveDocumentDialog } from './move-document-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
row: Document & { row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[]; recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string }; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
@ -66,9 +65,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return null; return null;
} }
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id; const isOwner = row.user.id === session.user.id;
// const isRecipient = !!recipient; // const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
@ -81,17 +80,13 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
let document: DocumentWithData | null = null; const document = !recipient
? await trpcClient.document.getDocumentById.query({
if (!recipient) { documentId: row.id,
document = await trpcClient.document.getDocumentById.query({ })
documentId: row.id, : await trpcClient.document.getDocumentByToken.query({
}); token: recipient.token,
} else { });
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData; const documentData = document?.documentData;
@ -109,7 +104,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
} }
}; };
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
<DropdownMenu> <DropdownMenu>
@ -167,7 +162,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */} {/* We don't want to allow teams moving documents across at the moment. */}
{!team && ( {!team && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}> <DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" /> <MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans> <Trans>Move to Team</Trans>
@ -194,7 +189,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
{canManageDocument && ( {canManageDocument && (
<DocumentRecipientLinkCopyDialog <DocumentRecipientLinkCopyDialog
recipients={row.Recipient} recipients={row.recipients}
trigger={ trigger={
<DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}> <DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}>
<div> <div>
@ -238,14 +233,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
onOpenChange={setMoveDialogOpen} onOpenChange={setMoveDialogOpen}
/> />
{isDuplicateDialogOpen && ( <DuplicateDocumentDialog
<DuplicateDocumentDialog id={row.id}
id={row.id} open={isDuplicateDialogOpen}
open={isDuplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen} team={team}
team={team} />
/>
)}
</DropdownMenu> </DropdownMenu>
); );
}; };

View File

@ -25,7 +25,7 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? ''); const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({ const { data, isLoading } = trpc.team.getTeamMembers.useQuery({
teamId, teamId,
}); });
@ -61,7 +61,7 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
} }
enableClearAllButton={true} enableClearAllButton={true}
inputPlaceholder={msg`Search`} inputPlaceholder={msg`Search`}
loading={!isMounted || isInitialLoading} loading={!isMounted || isLoading}
options={comboBoxOptions} options={comboBoxOptions}
selectedValues={senderIds} selectedValues={senderIds}
onChange={onChange} onChange={onChange}

View File

@ -10,9 +10,9 @@ import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
export type DataTableTitleProps = { export type DataTableTitleProps = {
row: Document & { row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'url'> | null; team: Pick<Team, 'url'> | null;
Recipient: Recipient[]; recipients: Recipient[];
}; };
teamUrl?: string; teamUrl?: string;
}; };
@ -24,9 +24,9 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
return null; return null;
} }
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id; const isOwner = row.user.id === session.user.id;
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl; const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;

View File

@ -9,9 +9,9 @@ import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TFindDocumentsResponse } from '@documenso/lib/server-only/document/find-documents';
import type { Team } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -57,14 +57,14 @@ export const DocumentsDataTable = ({
{ {
id: 'sender', id: 'sender',
header: _(msg`Sender`), header: _(msg`Sender`),
cell: ({ row }) => row.original.User.name ?? row.original.User.email, cell: ({ row }) => row.original.user.name ?? row.original.user.email,
}, },
{ {
header: _(msg`Recipient`), header: _(msg`Recipient`),
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => ( cell: ({ row }) => (
<StackAvatarsWithTooltip <StackAvatarsWithTooltip
recipients={row.original.Recipient} recipients={row.original.recipients}
documentStatus={row.original.status} documentStatus={row.original.status}
/> />
), ),

View File

@ -51,7 +51,7 @@ export const DeleteDocumentDialog = ({
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({ const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();
void refreshLimits(); void refreshLimits();
@ -92,7 +92,7 @@ export const DeleteDocumentDialog = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@ -193,7 +193,7 @@ export const DeleteDocumentDialog = ({
<Button <Button
type="button" type="button"
loading={isLoading} loading={isPending}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled && canManageDocument} disabled={!isDeleteEnabled && canManageDocument}
variant="destructive" variant="destructive"

View File

@ -48,10 +48,10 @@ export const DuplicateDocumentDialog = ({
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } = const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({ trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => { onSuccess: ({ documentId }) => {
router.push(`${documentsPath}/${newId}/edit`); router.push(`${documentsPath}/${documentId}/edit`);
toast({ toast({
title: _(msg`Document Duplicated`), title: _(msg`Document Duplicated`),

View File

@ -40,9 +40,9 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null); 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, isLoading } = trpc.document.moveDocumentToTeam.useMutation({ const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();
toast({ toast({
@ -119,8 +119,8 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
<Button variant="secondary" onClick={() => onOpenChange(false)}> <Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}> <Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>} {isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -8,16 +8,16 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
@ -99,25 +99,20 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
console.error(err); console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') { const errorMessage = match(error.code)
toast({ .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
title: _(msg`Invalid file`), .with(
description: _(msg`You cannot upload encrypted PDFs`), AppErrorCode.LIMIT_EXCEEDED,
variant: 'destructive', () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
}); )
} else if (err instanceof TRPCClientError) { .otherwise(() => msg`An error occurred while uploading your document.`);
toast({
title: _(msg`Error`), toast({
description: err.message, title: _(msg`Error`),
variant: 'destructive', description: _(errorMessage),
}); variant: 'destructive',
} else { duration: 7500,
toast({ });
title: _(msg`Error`),
description: _(msg`An error occurred while uploading your document.`),
variant: 'destructive',
});
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -36,7 +36,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
const [enteredEmail, setEnteredEmail] = useState<string>(''); const [enteredEmail, setEnteredEmail] = useState<string>('');
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = const { mutateAsync: deleteAccount, isPending: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation(); trpc.profile.deleteAccount.useMutation();
const onDeleteAccount = async () => { const onDeleteAccount = async () => {

View File

@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type { import type {
Team, Team,
TeamProfile, TeamProfile,
@ -15,6 +14,7 @@ import type {
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client'; import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Switch } from '@documenso/ui/primitives/switch'; import { Switch } from '@documenso/ui/primitives/switch';
@ -63,10 +63,10 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
perPage: 100, perPage: 100,
}); });
const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } = const { mutateAsync: updateUserProfile, isPending: isUpdatingUserProfile } =
trpc.profile.updatePublicProfile.useMutation(); trpc.profile.updatePublicProfile.useMutation();
const { mutateAsync: updateTeamProfile, isLoading: isUpdatingTeamProfile } = const { mutateAsync: updateTeamProfile, isPending: isUpdatingTeamProfile } =
trpc.team.updateTeamPublicProfile.useMutation(); trpc.team.updateTeamPublicProfile.useMutation();
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile; const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;

View File

@ -7,11 +7,11 @@ import { useLingui } from '@lingui/react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import type { TemplateDirectLink } from '@documenso/prisma/client'; import type { TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client'; import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -39,10 +39,10 @@ export const PublicTemplatesDataTable = () => {
templateId: number; templateId: number;
} | null>(null); } | null>(null);
const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery( const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
{}, {},
{ {
keepPreviousData: true, placeholderData: (previousData) => previousData,
}, },
); );
@ -80,7 +80,7 @@ export const PublicTemplatesDataTable = () => {
{/* Loading and error handling states. */} {/* Loading and error handling states. */}
{publicDirectTemplates.length === 0 && ( {publicDirectTemplates.length === 0 && (
<> <>
{isInitialLoading && {isLoading &&
Array(3) Array(3)
.fill(0) .fill(0)
.map((_, index) => ( .map((_, index) => (
@ -115,7 +115,7 @@ export const PublicTemplatesDataTable = () => {
</div> </div>
)} )}
{!isInitialLoading && ( {!isLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm"> <div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
<Trans>No public profile templates found</Trans> <Trans>No public profile templates found</Trans>
<ManagePublicTemplateDialog <ManagePublicTemplateDialog

View File

@ -35,16 +35,15 @@ export const UserSecurityActivityDataTable = () => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = const { data, isLoading, isLoadingError } = trpc.profile.findUserSecurityAuditLogs.useQuery(
trpc.profile.findUserSecurityAuditLogs.useQuery( {
{ page: parsedSearchParams.page,
page: parsedSearchParams.page, perPage: parsedSearchParams.perPage,
perPage: parsedSearchParams.perPage, },
}, {
{ placeholderData: (previousData) => previousData,
keepPreviousData: true, },
}, );
);
const onPaginationChange = (page: number, perPage: number) => { const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({ updateSearchParams({
@ -134,7 +133,7 @@ export const UserSecurityActivityDataTable = () => {
enable: isLoadingError, enable: isLoadingError,
}} }}
skeleton={{ skeleton={{
enable: isLoading && isInitialLoading, enable: isLoading,
rows: 3, rows: 3,
component: ( component: (
<> <>

View File

@ -65,7 +65,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
}, },
}); });
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } = const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
trpc.auth.createPasskeyRegistrationOptions.useMutation(); trpc.auth.createPasskeyRegistrationOptions.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation(); const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
@ -141,7 +141,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
> >
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}> <DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? ( {trigger ?? (
<Button variant="secondary" loading={isLoading}> <Button variant="secondary" loading={isPending}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" /> <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
<Trans>Add passkey</Trans> <Trans>Add passkey</Trans>
</Button> </Button>

View File

@ -60,7 +60,7 @@ export const UserPasskeysDataTableActions = ({
}, },
}); });
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } = const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
trpc.auth.updatePasskey.useMutation({ trpc.auth.updatePasskey.useMutation({
onSuccess: () => { onSuccess: () => {
toast({ toast({
@ -80,7 +80,7 @@ export const UserPasskeysDataTableActions = ({
}, },
}); });
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } = const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
trpc.auth.deletePasskey.useMutation({ trpc.auth.deletePasskey.useMutation({
onSuccess: () => { onSuccess: () => {
toast({ toast({

View File

@ -29,13 +29,13 @@ export const UserPasskeysDataTable = () => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery( const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
{ {
page: parsedSearchParams.page, page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage, perPage: parsedSearchParams.perPage,
}, },
{ {
keepPreviousData: true, placeholderData: (previousData) => previousData,
}, },
); );
@ -100,7 +100,7 @@ export const UserPasskeysDataTable = () => {
enable: isLoadingError, enable: isLoadingError,
}} }}
skeleton={{ skeleton={{
enable: isLoading && isInitialLoading, enable: isLoading,
rows: 3, rows: 3,
component: ( component: (
<> <>

View File

@ -17,7 +17,7 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
const { const {
mutateAsync: acceptTeamInvitation, mutateAsync: acceptTeamInvitation,
isLoading, isPending,
isSuccess, isSuccess,
} = trpc.team.acceptTeamInvitation.useMutation({ } = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => { onSuccess: () => {
@ -40,8 +40,8 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
return ( return (
<Button <Button
onClick={async () => acceptTeamInvitation({ teamId })} onClick={async () => acceptTeamInvitation({ teamId })}
loading={isLoading} loading={isPending}
disabled={isLoading || isSuccess} disabled={isPending || isSuccess}
> >
<Trans>Accept</Trans> <Trans>Accept</Trans>
</Button> </Button>

View File

@ -17,7 +17,7 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
const { const {
mutateAsync: declineTeamInvitation, mutateAsync: declineTeamInvitation,
isLoading, isPending,
isSuccess, isSuccess,
} = trpc.team.declineTeamInvitation.useMutation({ } = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => { onSuccess: () => {
@ -40,8 +40,8 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
return ( return (
<Button <Button
onClick={async () => declineTeamInvitation({ teamId })} onClick={async () => declineTeamInvitation({ teamId })}
loading={isLoading} loading={isPending}
disabled={isLoading || isSuccess} disabled={isPending || isSuccess}
variant="ghost" variant="ghost"
> >
<Trans>Decline</Trans> <Trans>Decline</Trans>

View File

@ -30,7 +30,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({ trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => { onSuccess: () => {
toast({ toast({

View File

@ -23,11 +23,11 @@ import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
import { DeclineTeamInvitationButton } from './decline-team-invitation-button'; import { DeclineTeamInvitationButton } from './decline-team-invitation-button';
export const TeamInvitations = () => { export const TeamInvitations = () => {
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery(); const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
return ( return (
<AnimatePresence> <AnimatePresence>
{data && data.length > 0 && !isInitialLoading && ( {data && data.length > 0 && !isLoading && (
<AnimateGenericFadeInOut> <AnimateGenericFadeInOut>
<Alert variant="secondary"> <Alert variant="secondary">
<div className="flex h-full flex-row items-center p-2"> <div className="flex h-full flex-row items-center p-2">

View File

@ -12,7 +12,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import type { TemplateWithDetails } from '@documenso/prisma/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -32,7 +32,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = { export type EditTemplateFormProps = {
className?: string; className?: string;
initialTemplate: TemplateWithDetails; initialTemplate: TTemplate;
isEnterprise: boolean; isEnterprise: boolean;
templateRootPath: string; templateRootPath: string;
}; };
@ -69,7 +69,7 @@ export const EditTemplateForm = ({
}, },
); );
const { Recipient: recipients, Field: fields, templateDocumentData } = template; const { recipients, fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = { const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: { settings: {

View File

@ -11,7 +11,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
export type TemplateDirectLinkDialogWrapperProps = { export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] }; template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
}; };
export const TemplateDirectLinkDialogWrapper = ({ export const TemplateDirectLinkDialogWrapper = ({

View File

@ -69,20 +69,19 @@ export const TemplatePageViewDocumentsTable = ({
Object.fromEntries(searchParams ?? []), Object.fromEntries(searchParams ?? []),
); );
const { data, isLoading, isInitialLoading, isLoadingError } = const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
trpc.document.findDocuments.useQuery( {
{ templateId,
templateId, page: parsedSearchParams.page,
page: parsedSearchParams.page, perPage: parsedSearchParams.perPage,
perPage: parsedSearchParams.perPage, query: parsedSearchParams.query,
query: parsedSearchParams.query, source: parsedSearchParams.source,
source: parsedSearchParams.source, status: parsedSearchParams.status,
status: parsedSearchParams.status, },
}, {
{ placeholderData: (previousData) => previousData,
keepPreviousData: true, },
}, );
);
const onPaginationChange = (page: number, perPage: number) => { const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({ updateSearchParams({
@ -116,7 +115,7 @@ export const TemplatePageViewDocumentsTable = ({
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => ( cell: ({ row }) => (
<StackAvatarsWithTooltip <StackAvatarsWithTooltip
recipients={row.original.Recipient} recipients={row.original.recipients}
documentStatus={row.original.status} documentStatus={row.original.status}
/> />
), ),
@ -241,7 +240,7 @@ export const TemplatePageViewDocumentsTable = ({
enable: isLoadingError, enable: isLoadingError,
}} }}
skeleton={{ skeleton={{
enable: isLoading && isInitialLoading, enable: isLoading,
rows: 3, rows: 3,
component: ( component: (
<> <>

View File

@ -12,7 +12,7 @@ import type { Template, User } from '@documenso/prisma/client';
export type TemplatePageViewInformationProps = { export type TemplatePageViewInformationProps = {
userId: number; userId: number;
template: Template & { template: Template & {
User: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
}; };
}; };
@ -28,7 +28,8 @@ export const TemplatePageViewInformation = ({
return [ return [
{ {
description: msg`Uploaded by`, description: msg`Uploaded by`,
value: userId === template.userId ? _(msg`You`) : template.User.name ?? template.User.email, value:
userId === template.userId ? _(msg`You`) : (template.user.name ?? template.user.email),
}, },
{ {
description: msg`Created`, description: msg`Created`,

View File

@ -123,7 +123,7 @@ export const TemplatePageViewRecentActivity = ({
{match(document.source) {match(document.source)
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => ( .with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
<Trans> <Trans>
Document created by <span className="font-bold">{document.User.name}</span> Document created by <span className="font-bold">{document.user.name}</span>
</Trans> </Trans>
)) ))
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => ( .with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (

View File

@ -10,7 +10,7 @@ import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = { export type TemplatePageViewRecipientsProps = {
template: Template & { template: Template & {
Recipient: Recipient[]; recipients: Recipient[];
}; };
templateRootPath: string; templateRootPath: string;
}; };
@ -21,7 +21,7 @@ export const TemplatePageViewRecipients = ({
}: TemplatePageViewRecipientsProps) => { }: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const recipients = template.Recipient; const recipients = template.recipients;
return ( return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">

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 { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type'; import { TemplateType } from '~/components/formatter/template-type';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DataTableActionDropdown } from '../data-table-action-dropdown'; import { DataTableActionDropdown } from '../data-table-action-dropdown';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
@ -54,10 +55,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath); redirect(templateRootPath);
} }
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template; const { templateDocumentData, fields, recipients, templateMeta } = template;
// Remap to fit the DocumentReadOnlyFields component. // Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = Field.map((field) => { const readOnlyFields = fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || { const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
name: '', name: '',
email: '', email: '',
@ -66,8 +67,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
return { return {
...field, ...field,
Recipient: recipient, recipient,
Signature: null, signature: null,
}; };
}); });
@ -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"> <div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} /> <TemplateDirectLinkDialogWrapper template={template} />
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<Button className="w-full" asChild> <Button className="w-full" asChild>
<Link href={`${templateRootPath}/${template.id}/edit`}> <Link href={`${templateRootPath}/${template.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" /> <LucideEdit className="mr-1.5 h-3.5 w-3.5" />
@ -165,7 +168,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
<UseTemplateDialog <UseTemplateDialog
templateId={template.id} templateId={template.id}
templateSigningOrder={template.templateMeta?.signingOrder} templateSigningOrder={template.templateMeta?.signingOrder}
recipients={template.Recipient} recipients={template.recipients}
documentRootPath={documentRootPath} documentRootPath={documentRootPath}
trigger={ trigger={
<Button className="w-full"> <Button className="w-full">

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Trans } from '@lingui/macro'; 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 { useSession } from 'next-auth/react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
@ -17,6 +17,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DeleteTemplateDialog } from './delete-template-dialog'; import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog'; import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { MoveTemplateDialog } from './move-template-dialog'; import { MoveTemplateDialog } from './move-template-dialog';
@ -25,7 +27,7 @@ import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
row: Template & { row: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null; directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[]; recipients: Recipient[];
}; };
templateRootPath: string; templateRootPath: string;
teamId?: number; teamId?: number;
@ -79,13 +81,24 @@ export const DataTableActionDropdown = ({
<Trans>Direct link</Trans> <Trans>Direct link</Trans>
</DropdownMenuItem> </DropdownMenuItem>
{!teamId && ( {!teamId && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}> <DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" /> <MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans> <Trans>Move to Team</Trans>
</DropdownMenuItem> </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 <DropdownMenuItem
disabled={!isOwner && !isTeamTemplate} disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}

View File

@ -10,7 +10,7 @@ import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates'; import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
@ -146,7 +146,7 @@ export const TemplatesDataTable = ({
templateId={row.original.id} templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder} templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod} documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.Recipient} recipients={row.original.recipients}
documentRootPath={documentRootPath} documentRootPath={documentRootPath}
/> />

View File

@ -28,7 +28,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({ const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();
@ -51,7 +51,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
}); });
return ( return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@ -70,7 +70,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={isLoading} disabled={isPending}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
@ -79,7 +79,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
loading={isLoading} loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })} onClick={async () => deleteTemplate({ templateId: id })}
> >
<Trans>Delete</Trans> <Trans>Delete</Trans>

View File

@ -32,7 +32,7 @@ export const DuplicateTemplateDialog = ({
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } = const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({ trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();
@ -55,7 +55,7 @@ export const DuplicateTemplateDialog = ({
}); });
return ( return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@ -70,7 +70,7 @@ export const DuplicateTemplateDialog = ({
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
disabled={isLoading} disabled={isPending}
variant="secondary" variant="secondary"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
@ -79,7 +79,7 @@ export const DuplicateTemplateDialog = ({
<Button <Button
type="button" type="button"
loading={isLoading} loading={isPending}
onClick={async () => onClick={async () =>
duplicateTemplate({ duplicateTemplate({
templateId: id, templateId: id,

View File

@ -42,8 +42,8 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null); 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, isLoading } = trpc.template.moveTemplateToTeam.useMutation({ const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();
toast({ toast({
@ -130,8 +130,8 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<Button variant="secondary" onClick={() => onOpenChange(false)}> <Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}> <Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>} {isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -46,12 +46,10 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
type TemplateDirectLinkDialogProps = { type TemplateDirectLinkDialogProps = {
template: Template & { template: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null; directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[]; recipients: Recipient[];
}; };
open: boolean; open: boolean;
onOpenChange: (_open: boolean) => void; onOpenChange: (_open: boolean) => void;
@ -68,8 +66,6 @@ export const TemplateDirectLinkDialog = ({
const { quota, remaining } = useLimits(); const { quota, remaining } = useLimits();
const { _ } = useLingui(); const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const router = useRouter(); const router = useRouter();
@ -81,13 +77,17 @@ export const TemplateDirectLinkDialog = ({
); );
const validDirectTemplateRecipients = useMemo( const validDirectTemplateRecipients = useMemo(
() => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC), () =>
[template.Recipient], template.recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
),
[template.recipients],
); );
const { const {
mutateAsync: createTemplateDirectLink, mutateAsync: createTemplateDirectLink,
isLoading: isCreatingTemplateDirectLink, isPending: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink, reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({ } = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@ -108,7 +108,7 @@ export const TemplateDirectLinkDialog = ({
}, },
}); });
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } = const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({ trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
const enabledDescription = msg`Direct link signing has been enabled`; const enabledDescription = msg`Direct link signing has been enabled`;
@ -131,7 +131,7 @@ export const TemplateDirectLinkDialog = ({
}, },
}); });
const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } = const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({ trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => { onSuccess: () => {
onOpenChange(false); onOpenChange(false);
@ -326,7 +326,7 @@ export const TemplateDirectLinkDialog = ({
</div> </div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */} {/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!template.Recipient.some( {!template.recipients.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && ( ) && (
<DialogFooter className="mx-auto"> <DialogFooter className="mx-auto">

View File

@ -104,7 +104,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
<span className="font-medium">{_(msg`Owner`)}</span> <span className="font-medium">{_(msg`Owner`)}</span>
<span className="mt-1 block break-words"> <span className="mt-1 block break-words">
{document.User.name} ({document.User.email}) {document.user.name} ({document.user.email})
</span> </span>
</p> </p>
@ -140,7 +140,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
<p className="font-medium">{_(msg`Recipients`)}</p> <p className="font-medium">{_(msg`Recipients`)}</p>
<ul className="mt-1 list-inside list-disc"> <ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => ( {document.recipients.map((recipient) => (
<li key={recipient.id}> <li key={recipient.id}>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] [{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]

View File

@ -86,7 +86,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
}); });
const isOwner = (email: string) => { const isOwner = (email: string) => {
return email.toLowerCase() === document.User.email.toLowerCase(); return email.toLowerCase() === document.user.email.toLowerCase();
}; };
const getDevice = (userAgent?: string | null) => { const getDevice = (userAgent?: string | null) => {
@ -104,7 +104,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
}; };
const getAuthenticationLevel = (recipientId: number) => { const getAuthenticationLevel = (recipientId: number) => {
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId); const recipient = document.recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) { if (!recipient) {
return 'Unknown'; return 'Unknown';
@ -157,9 +157,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
}; };
const getRecipientSignatureField = (recipientId: number) => { const getRecipientSignatureField = (recipientId: number) => {
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find( return document.recipients
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE, .find((recipient) => recipient.id === recipientId)
); ?.fields.find(
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
);
}; };
return ( return (
@ -181,7 +183,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
</TableHeader> </TableHeader>
<TableBody className="print:text-xs"> <TableBody className="print:text-xs">
{document.Recipient.map((recipient, i) => { {document.recipients.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id); const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id); const signature = getRecipientSignatureField(recipient.id);
@ -209,17 +211,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`, boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}} }}
> >
{signature.Signature?.signatureImageAsBase64 && ( {signature.signature?.signatureImageAsBase64 && (
<img <img
src={`${signature.Signature?.signatureImageAsBase64}`} src={`${signature.signature?.signatureImageAsBase64}`}
alt="Signature" alt="Signature"
className="max-h-12 max-w-full" className="max-h-12 max-w-full"
/> />
)} )}
{signature.Signature?.typedSignature && ( {signature.signature?.typedSignature && (
<p className="font-signature text-center text-sm"> <p className="font-signature text-center text-sm">
{signature.Signature?.typedSignature} {signature.signature?.typedSignature}
</p> </p>
)} )}
</div> </div>

View File

@ -7,8 +7,9 @@ import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { TTemplate } from '@documenso/lib/types/template';
import type { TemplateWithDetails } from '@documenso/prisma/types/template'; import type { Recipient } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -40,8 +41,8 @@ export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirect
export type ConfigureDirectTemplateFormProps = { export type ConfigureDirectTemplateFormProps = {
flowStep: DocumentFlowStep; flowStep: DocumentFlowStep;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
template: Omit<TemplateWithDetails, 'User'>; template: Omit<TTemplate, 'user'>;
directTemplateRecipient: Recipient & { Field: Field[] }; directTemplateRecipient: Recipient & { fields: Field[] };
initialEmail?: string; initialEmail?: string;
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void; onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;
}; };
@ -57,10 +58,10 @@ export const ConfigureDirectTemplateFormPartial = ({
const { _ } = useLingui(); const { _ } = useLingui();
const { data: session } = useSession(); const { data: session } = useSession();
const { Recipient } = template; const { recipients } = template;
const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext(); const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext();
const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => { const recipientsWithBlankDirectRecipientEmail = recipients.map((recipient) => {
if (recipient.id === directTemplateRecipient.id) { if (recipient.id === directTemplateRecipient.id) {
return { return {
...recipient, ...recipient,
@ -74,7 +75,7 @@ export const ConfigureDirectTemplateFormPartial = ({
const form = useForm<TConfigureDirectTemplateFormSchema>({ const form = useForm<TConfigureDirectTemplateFormSchema>({
resolver: zodResolver( resolver: zodResolver(
ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => { ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => {
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) { if (template.recipients.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: _(msg`Email cannot already exist in the template`), message: _(msg`Email cannot already exist in the template`),
@ -96,7 +97,7 @@ export const ConfigureDirectTemplateFormPartial = ({
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && {isDocumentPdfLoaded &&
directTemplateRecipient.Field.map((field, index) => ( directTemplateRecipient.fields.map((field, index) => (
<ShowFieldItem <ShowFieldItem
key={index} key={index}
field={field} field={field}

View File

@ -8,9 +8,9 @@ import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import type { Field } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@ -28,9 +28,9 @@ import type { DirectTemplateLocalField } from './sign-direct-template';
import { SignDirectTemplateForm } from './sign-direct-template'; import { SignDirectTemplateForm } from './sign-direct-template';
export type TemplatesDirectPageViewProps = { export type TemplatesDirectPageViewProps = {
template: Omit<TemplateWithDetails, 'User'>; template: Omit<TTemplate, 'user'>;
directTemplateToken: string; directTemplateToken: string;
directTemplateRecipient: Recipient & { Field: Field[] }; directTemplateRecipient: Recipient & { fields: Field[] };
}; };
type DirectTemplateStep = 'configure' | 'sign'; type DirectTemplateStep = 'configure' | 'sign';
@ -164,7 +164,7 @@ export const DirectTemplatePageView = ({
<SignDirectTemplateForm <SignDirectTemplateForm
flowStep={directTemplateFlow.sign} flowStep={directTemplateFlow.sign}
directRecipient={recipient} directRecipient={recipient}
directRecipientFields={directTemplateRecipient.Field} directRecipientFields={directTemplateRecipient.fields}
template={template} template={template}
onSubmit={onSignDirectTemplateSubmit} onSubmit={onSignDirectTemplateSubmit}
/> />

View File

@ -41,7 +41,7 @@ export default async function TemplatesDirectPage({ params }: TemplatesDirectPag
notFound(); notFound();
} }
const directTemplateRecipient = template.Recipient.find( const directTemplateRecipient = template.recipients.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId, (recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
); );
@ -81,7 +81,7 @@ export default async function TemplatesDirectPage({ params }: TemplatesDirectPag
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2"> <div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" /> <UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80"> <p className="text-muted-foreground/80">
<Plural value={template.Recipient.length} one="# recipient" other="# recipients" /> <Plural value={template.recipients.length} one="# recipient" other="# recipients" />
</p> </p>
</div> </div>

View File

@ -14,10 +14,10 @@ import {
ZRadioFieldMeta, ZRadioFieldMeta,
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { TTemplate } from '@documenso/lib/types/template';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client'; import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import type { import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; 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 { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field';
@ -55,13 +56,13 @@ export type SignDirectTemplateFormProps = {
flowStep: DocumentFlowStep; flowStep: DocumentFlowStep;
directRecipient: Recipient; directRecipient: Recipient;
directRecipientFields: Field[]; directRecipientFields: Field[];
template: Omit<TemplateWithDetails, 'User'>; template: Omit<TTemplate, 'user'>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>; onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
}; };
export type DirectTemplateLocalField = Field & { export type DirectTemplateLocalField = Field & {
signedValue?: TSignFieldWithTokenMutationSchema; signedValue?: TSignFieldWithTokenMutationSchema;
Signature?: Signature; signature?: Signature;
}; };
export const SignDirectTemplateForm = ({ export const SignDirectTemplateForm = ({
@ -95,7 +96,7 @@ export const SignDirectTemplateForm = ({
}; };
if (field.type === FieldType.SIGNATURE) { if (field.type === FieldType.SIGNATURE) {
tempField.Signature = { tempField.signature = {
id: 1, id: 1,
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
@ -127,7 +128,7 @@ export const SignDirectTemplateForm = ({
customText: '', customText: '',
inserted: false, inserted: false,
signedValue: undefined, signedValue: undefined,
Signature: undefined, signature: undefined,
}; };
}), }),
); );
@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({
}; };
return ( return (
<> <RecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
@ -186,16 +187,15 @@ export const SignDirectTemplateForm = ({
<SignatureField <SignatureField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (
<InitialsField <InitialsField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -204,7 +204,6 @@ export const SignDirectTemplateForm = ({
<NameField <NameField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -213,7 +212,6 @@ export const SignDirectTemplateForm = ({
<DateField <DateField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT} dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE} timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField} onSignField={onSignField}
@ -224,7 +222,6 @@ export const SignDirectTemplateForm = ({
<EmailField <EmailField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -241,7 +238,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -259,7 +255,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -277,7 +272,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -295,7 +289,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -313,7 +306,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -351,6 +343,7 @@ export const SignDirectTemplateForm = ({
onChange={(value) => { onChange={(value) => {
setSignature(value); setSignature(value);
}} }}
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -383,6 +376,6 @@ export const SignDirectTemplateForm = ({
/> />
</div> </div>
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
</> </RecipientProvider>
); );
}; };

View File

@ -52,15 +52,15 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
const isRecipient = 'Signature' in recipientOrSender; const isRecipient = 'Signature' in recipientOrSender;
const signatureImage = match(recipientOrSender) const signatureImage = match(recipientOrSender)
.with({ Signature: P.array(P._) }, (recipient) => { .with({ signatures: P.array(P._) }, (recipient) => {
return recipient.Signature?.[0]?.signatureImageAsBase64 || null; return recipient.signatures?.[0]?.signatureImageAsBase64 || null;
}) })
.otherwise((sender) => { .otherwise((sender) => {
return sender.signature || null; return sender.signature || null;
}); });
const signatureName = match(recipientOrSender) const signatureName = match(recipientOrSender)
.with({ Signature: P.array(P._) }, (recipient) => { .with({ signatures: P.array(P._) }, (recipient) => {
return recipient.name || recipient.email; return recipient.name || recipient.email;
}) })
.otherwise((sender) => { .otherwise((sender) => {

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 type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,23 +26,19 @@ import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = { export type CheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const CheckboxField = ({ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => {
field,
recipient,
onSignField,
onUnsignField,
}: CheckboxFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -81,12 +76,12 @@ export const CheckboxField = ({
); );
}, [checkedValues, validationSign, checkboxValidationLength]); }, [checkedValues, validationSign, checkboxValidationLength]);
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@ -122,7 +117,9 @@ export const CheckboxField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -151,7 +148,7 @@ export const CheckboxField = ({
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -183,28 +180,25 @@ export const CheckboxField = ({
...checkedValues, ...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`, 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 { } else {
updatedValues = checkedValues.filter( updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`, (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, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: toCheckboxValue(updatedValues),
isBase64: true,
}); });
} }
} catch (err) { } catch (err) {
@ -216,7 +210,6 @@ export const CheckboxField = ({
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
setCheckedValues(updatedValues);
startTransition(() => router.refresh()); 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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = { export type DateFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null; dateFormat?: string | null;
timezone?: string | null; timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -38,33 +39,34 @@ export type DateFieldProps = {
export const DateField = ({ export const DateField = ({
field, field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE, timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField, onSignField,
onUnsignField, onUnsignField,
}: DateFieldProps) => { }: DateFieldProps) => {
const router = useRouter(); const router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = 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 isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText; const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = _( const tooltipText = _(
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`, msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`,
); );
@ -97,7 +99,9 @@ export const DateField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -123,7 +127,7 @@ export const DateField = ({
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -150,9 +154,21 @@ export const DateField = ({
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
{localDateString} <p
</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> </SigningFieldContainer>
); );

View File

@ -106,7 +106,7 @@ export const DocumentAuthProvider = ({
perPage: MAXIMUM_PASSKEYS, perPage: MAXIMUM_PASSKEYS,
}, },
{ {
keepPreviousData: true, placeholderData: (previousData) => previousData,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
}, },
); );

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

View File

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

View File

@ -1,19 +1,22 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useId, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { 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 { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; 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 { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; 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 { 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 { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog'; import { SignDialog } from './sign-dialog';
@ -32,6 +38,8 @@ export type SigningFormProps = {
fields: Field[]; fields: Field[];
redirectUrl?: string | null; redirectUrl?: string | null;
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
}; };
export const SigningForm = ({ export const SigningForm = ({
@ -40,19 +48,35 @@ export const SigningForm = ({
fields, fields,
redirectUrl, redirectUrl,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
}: SigningFormProps) => { }: SigningFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredSigningContext(); useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
const { handleSubmit, formState } = useForm(); const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
@ -67,7 +91,11 @@ export const SigningForm = ({
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
}, [fields]); }, [fieldsRequiringValidation]);
const uninsertedRecipientFields = useMemo(() => {
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => { const fieldsValidated = () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
@ -88,12 +116,31 @@ export const SigningForm = ({
} }
await completeDocument(); await completeDocument();
};
// Reauth is currently not required for completing the document. const onAssistantFormSubmit = () => {
// await executeActionAuthProcedure({ if (uninsertedRecipientFields.length > 0) {
// onReauthFormSubmit: completeDocument, return;
// actionTarget: 'DOCUMENT', }
// });
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) => { const completeDocument = async (authOptions?: TRecipientActionAuth) => {
@ -113,7 +160,7 @@ export const SigningForm = ({
}; };
return ( return (
<form <div
className={cn( className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6', '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, 'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
}, },
)} )}
onSubmit={handleSubmit(onFormSubmit)}
> >
{validateUninsertedFields && uninsertedFields[0] && ( {validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning"> <FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
@ -129,17 +175,13 @@ export const SigningForm = ({
</FieldToolTip> </FieldToolTip>
)} )}
<fieldset <div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
disabled={isSubmitting} <div className="flex flex-1 flex-col">
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')}>
<h3 className="text-foreground text-2xl font-semibold"> <h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>} {recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>} {recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>} {recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3> </h3>
{recipient.role === RecipientRole.VIEWER ? ( {recipient.role === RecipientRole.VIEWER ? (
@ -176,91 +218,185 @@ export const SigningForm = ({
</div> </div>
</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"> <form onSubmit={handleSubmit(onFormSubmit)}>
<Trans>Please review the document before signing.</Trans> <p className="text-muted-foreground mt-2 text-sm">
</p> <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"> <fieldset
<div className="flex flex-1 flex-col gap-y-4"> disabled={isSubmitting}
<div> className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
<Label htmlFor="full-name"> >
<Trans>Full Name</Trans> <div className="flex flex-1 flex-col gap-y-4">
</Label> <div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input <Input
type="text" type="text"
id="full-name" id="full-name"
className="bg-background mt-2" className="bg-background mt-2"
value={fullName} value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())} 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>
</fieldset>
<div> </form>
<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>
</> </>
)} )}
</div> </div>
</fieldset> </div>
</form> </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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -22,36 +21,32 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type InitialsFieldProps = { export type InitialsFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const InitialsField = ({ export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => {
field,
recipient,
onSignField,
onUnsignField,
}: InitialsFieldProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
const { fullName } = useRequiredSigningContext(); const { fullName } = useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const initials = extractInitials(fullName); const initials = extractInitials(fullName);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@ -87,7 +82,9 @@ export const InitialsField = ({
toast({ toast({
title: _(msg`Error`), 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', 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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input'; 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 { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = { export type NameFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => 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 router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
@ -43,26 +44,30 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const { fullName: providedFullName, setFullName: setProvidedFullName } = const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext(); useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = 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 isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showFullNameModal, setShowFullNameModal] = useState(false); const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState(''); const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => { const onPreSign = () => {
if (!providedFullName) { if (!providedFullName && !isAssistantMode) {
setShowFullNameModal(true); setShowFullNameModal(true);
return false; return false;
} }
@ -85,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
try { try {
const value = name || providedFullName; const value = name || providedFullName || '';
if (!value) { if (!value && !isAssistantMode) {
setShowFullNameModal(true); setShowFullNameModal(true);
return; return;
} }
@ -119,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -145,7 +152,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -172,9 +179,21 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
{field.customText} <p
</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}> <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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = { type ValidationErrors = {
@ -39,21 +39,32 @@ type ValidationErrors = {
export type NumberFieldProps = { export type NumberFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => 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 { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); 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 defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState( const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
@ -71,16 +82,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value; const text = e.target.value;
setLocalNumber(text); setLocalNumber(text);
@ -104,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
}; };
const onDialogSignClick = () => { const onDialogSignClick = () => {
setShowRadioModal(false); setShowNumberModal(false);
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions), onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -147,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
}; };
const onPreSign = () => { const onPreSign = () => {
setShowRadioModal(true); if (isAssistantMode) {
return true;
}
setShowNumberModal(true);
if (localNumber && parsedFieldMeta) { if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
@ -192,23 +199,23 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
}; };
useEffect(() => { useEffect(() => {
if (!showRadioModal) { if (!showNumberModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors); setErrors(initialErrors);
} }
}, [showRadioModal]); }, [showNumberModal]);
useEffect(() => { useEffect(() => {
if ( if (
(!field.inserted && defaultValue && localNumber) || (!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue) (!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
) { ) {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions), onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -221,8 +228,8 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
if (parsedFieldMeta?.label) { if (parsedFieldMeta?.label) {
fieldDisplayName = fieldDisplayName =
parsedFieldMeta.label.length > 10 parsedFieldMeta.label.length > 20
? parsedFieldMeta.label.substring(0, 10) + '...' ? parsedFieldMeta.label.substring(0, 20) + '...'
: parsedFieldMeta.label; : parsedFieldMeta.label;
} }
@ -234,7 +241,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
onPreSign={onPreSign} onPreSign={onPreSign}
onSign={onSign} onSign={onSign}
onRemove={onRemove} onRemove={onRemove}
type="Signature" type="Number"
> >
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -260,12 +267,24 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
{field.customText} <p
</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> <DialogContent>
<DialogTitle> <DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>} {parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
@ -321,7 +340,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" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowRadioModal(false); setShowNumberModal(false);
setLocalNumber(''); 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 { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; 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 { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; 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 { DocumentAuthProvider } from './document-auth-provider';
import { NoLongerAvailable } from './no-longer-available'; import { NoLongerAvailable } from './no-longer-available';
@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient, completedFields] = await Promise.all([ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
requireAccessAuth: false, requireAccessAuth: false,
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }), getCompletedFieldsForToken({ token }),
]); ]);
@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound(); return notFound();
} }
const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) { if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`); return redirect(`/sign/${token}/waiting`);
} }
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({
token,
})
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
user={user} user={user}
> >
<SigningPageView <SigningPageView
recipient={recipient} recipient={recipientWithFields}
document={document} document={document}
fields={fields} fields={fields}
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type RadioFieldProps = { export type RadioFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => 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 { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -52,12 +52,12 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@ -99,7 +99,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -126,7 +128,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the selection.`),
variant: 'destructive', 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

@ -43,9 +43,10 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface RejectDocumentDialogProps { export interface RejectDocumentDialogProps {
document: Pick<Document, 'id'>; document: Pick<Document, 'id'>;
token: string; token: string;
onRejected?: (reason: string) => void | Promise<void>;
} }
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) { export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -79,7 +80,11 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr
setIsOpen(false); setIsOpen(false);
router.push(`/sign/${token}/rejected`); if (onRejected) {
await onRejected(reason);
} else {
router.push(`/sign/${token}/rejected`);
}
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',

View File

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

View File

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

View File

@ -1,3 +1,7 @@
'use client';
import { useState } from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -13,9 +17,10 @@ import {
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields'; 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 { FieldType, RecipientRole } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -32,16 +37,18 @@ import { InitialsField } from './initials-field';
import { NameField } from './name-field'; import { NameField } from './name-field';
import { NumberField } from './number-field'; import { NumberField } from './number-field';
import { RadioField } from './radio-field'; import { RadioField } from './radio-field';
import { RecipientProvider } from './recipient-context';
import { RejectDocumentDialog } from './reject-document-dialog'; import { RejectDocumentDialog } from './reject-document-dialog';
import { SignatureField } from './signature-field'; import { SignatureField } from './signature-field';
import { TextField } from './text-field'; import { TextField } from './text-field';
export type SigningPageViewProps = { export type SigningPageViewProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
}; };
export const SigningPageView = ({ export const SigningPageView = ({
@ -50,167 +57,185 @@ export const SigningPageView = ({
fields, fields,
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [],
}: SigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const shouldUseTeamDetails = const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false; document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
let senderName = document.User.name ?? ''; let senderName = document.user.name ?? '';
let senderEmail = `(${document.User.email})`; let senderEmail = `(${document.user.email})`;
if (shouldUseTeamDetails) { if (shouldUseTeamDetails) {
senderName = document.team?.name ?? ''; senderName = document.team?.name ?? '';
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : ''; senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
} }
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
return ( return (
<div className="mx-auto w-full max-w-screen-xl"> <RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<h1 <div className="mx-auto w-full max-w-screen-xl">
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" <h1
title={document.title} 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
> >
<CardContent className="p-2"> {document.title}
<LazyPDFViewer </h1>
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4"> <div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<SigningForm <div className="max-w-[50ch]">
document={document} <span className="text-muted-foreground truncate" title={senderName}>
recipient={recipient} {senderName} {senderEmail}
fields={fields} </span>{' '}
redirectUrl={documentMeta?.redirectUrl} <span className="text-muted-foreground">
isRecipientsTurn={isRecipientsTurn} {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>
</div>
<DocumentReadOnlyFields fields={completedFields} /> <div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
<AutoSign recipient={recipient} fields={fields} /> className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> >
{fields.map((field) => <CardContent className="p-2">
match(field.type) <LazyPDFViewer
.with(FieldType.SIGNATURE, () => ( key={documentData.id}
<SignatureField documentData={documentData}
key={field.id} document={document}
field={field} password={documentMeta?.password}
recipient={recipient}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/> />
)) </CardContent>
.with(FieldType.INITIALS, () => ( </Card>
<InitialsField key={field.id} field={field} recipient={recipient} />
)) <div className="col-span-12 lg:col-span-5 xl:col-span-4">
.with(FieldType.NAME, () => ( <SigningForm
<NameField key={field.id} field={field} recipient={recipient} /> document={document}
)) recipient={recipient}
.with(FieldType.DATE, () => ( fields={fields}
<DateField redirectUrl={documentMeta?.redirectUrl}
key={field.id} isRecipientsTurn={isRecipientsTurn}
field={field} allRecipients={allRecipients}
recipient={recipient} setSelectedSignerId={setSelectedSignerId}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT} />
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE} </div>
/> </div>
))
.with(FieldType.EMAIL, () => ( <DocumentReadOnlyFields fields={completedFields} />
<EmailField key={field.id} field={field} recipient={recipient} />
)) {recipient.role !== RecipientRole.ASSISTANT && (
.with(FieldType.TEXT, () => { <AutoSign recipient={recipient} fields={fields} />
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),
)} )}
</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}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
required: string[];
characterLimit: string[];
};
export type TextFieldProps = { export type TextFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => 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 { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const initialErrors: Record<string, string[]> = { const initialErrors: ValidationErrors = {
required: [], required: [],
characterLimit: [], characterLimit: [],
}; };
const [errors, setErrors] = useState(initialErrors); const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@ -54,15 +58,16 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = 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 isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField = const shouldAutoSignField =
@ -165,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -193,7 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the text.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -233,7 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
onPreSign={onPreSign} onPreSign={onPreSign}
onSign={onSign} onSign={onSign}
onRemove={onRemove} onRemove={onRemove}
type="Signature" type="Text"
> >
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <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 && ( {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"> <div className="flex h-full w-full items-center">
{field.customText.length < 20 <p
? field.customText className={cn(
: field.customText.substring(0, 15) + '...'} 'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
</p> {
'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}> <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', { 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': '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, userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})} })}
value={localText} value={localText}
onChange={handleTextChange} onChange={handleTextChange}

View File

@ -38,7 +38,7 @@ export const LayoutBillingBanner = ({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: createBillingPortal, isLoading } = const { mutateAsync: createBillingPortal, isPending } =
trpc.team.createBillingPortal.useMutation(); trpc.team.createBillingPortal.useMutation();
const handleCreatePortal = async () => { const handleCreatePortal = async () => {
@ -92,7 +92,7 @@ export const LayoutBillingBanner = ({
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white': 'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscription.status === SubscriptionStatus.INACTIVE, subscription.status === SubscriptionStatus.INACTIVE,
})} })}
disabled={isLoading} disabled={isPending}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
size="sm" size="sm"
> >
@ -101,7 +101,7 @@ export const LayoutBillingBanner = ({
</div> </div>
</div> </div>
<Dialog open={isOpen} onOpenChange={(value) => !isLoading && setIsOpen(value)}> <Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
<DialogContent> <DialogContent>
<DialogTitle> <DialogTitle>
<Trans>Payment overdue</Trans> <Trans>Payment overdue</Trans>
@ -128,7 +128,7 @@ export const LayoutBillingBanner = ({
{canExecuteTeamAction('MANAGE_BILLING', userRole) && ( {canExecuteTeamAction('MANAGE_BILLING', userRole) && (
<DialogFooter> <DialogFooter>
<Button loading={isLoading} onClick={handleCreatePortal}> <Button loading={isPending} onClick={handleCreatePortal}>
<Trans>Resolve payment</Trans> <Trans>Resolve payment</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -25,7 +25,7 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } = const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({ trpc.team.resendTeamEmailVerification.useMutation({
onSuccess: () => { onSuccess: () => {
toast({ toast({

View File

@ -36,7 +36,7 @@ export const TeamTransferStatus = ({
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt); const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
const { mutateAsync: deleteTeamTransferRequest, isLoading } = const { mutateAsync: deleteTeamTransferRequest, isPending } =
trpc.team.deleteTeamTransferRequest.useMutation({ trpc.team.deleteTeamTransferRequest.useMutation({
onSuccess: () => { onSuccess: () => {
if (!isExpired) { if (!isExpired) {
@ -112,7 +112,7 @@ export const TeamTransferStatus = ({
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && ( {canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
<Button <Button
onClick={async () => deleteTeamTransferRequest({ teamId })} onClick={async () => deleteTeamTransferRequest({ teamId })}
loading={isLoading} loading={isPending}
variant={isExpired ? 'destructive' : 'ghost'} variant={isExpired ? 'destructive' : 'ghost'}
className={cn('ml-auto', { className={cn('ml-auto', {
'hover:bg-transparent hover:text-blue-800': !isExpired, 'hover:bg-transparent hover:text-blue-800': !isExpired,

View File

@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature }); console.log({ signature });
return ( 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"> <h3 className="text-foreground text-2xl font-semibold">
<Trans>Document Completed!</Trans> <Trans>Document Completed!</Trans>
</h3> </h3>

View File

@ -100,7 +100,7 @@ export const EmbedDirectTemplateClientPage = ({
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const { mutateAsync: createDocumentFromDirectTemplate, isLoading: isSubmitting } = const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation(); trpc.template.createDocumentFromDirectTemplate.useMutation();
const onSignField = (payload: TSignFieldWithTokenMutationSchema) => { const onSignField = (payload: TSignFieldWithTokenMutationSchema) => {
@ -118,7 +118,7 @@ export const EmbedDirectTemplateClientPage = ({
}); });
if (field.type === FieldType.SIGNATURE) { if (field.type === FieldType.SIGNATURE) {
newField.Signature = { newField.signature = {
id: 1, id: 1,
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
@ -163,7 +163,7 @@ export const EmbedDirectTemplateClientPage = ({
customText: '', customText: '',
inserted: false, inserted: false,
signedValue: undefined, signedValue: undefined,
Signature: undefined, signature: undefined,
}); });
}), }),
); );
@ -485,7 +485,6 @@ export const EmbedDirectTemplateClientPage = ({
{/* Fields */} {/* Fields */}
<EmbedDocumentFields <EmbedDocumentFields
recipient={recipient}
fields={localFields} fields={localFields}
metadata={metadata} metadata={metadata}
onSignField={onSignField} 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 { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { EmbedAuthenticateView } from '../../authenticate'; import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall'; import { EmbedPaywall } from '../../paywall';
@ -73,7 +74,7 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
const { directTemplateRecipientId } = template.directLink; const { directTemplateRecipientId } = template.directLink;
const recipient = template.Recipient.find( const recipient = template.recipients.find(
(recipient) => recipient.id === directTemplateRecipientId, (recipient) => recipient.id === directTemplateRecipientId,
); );
@ -81,7 +82,7 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
return notFound(); return notFound();
} }
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId); const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
const team = template.teamId const team = template.teamId
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null) ? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
@ -96,16 +97,18 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EmbedDirectTemplateClientPage <RecipientProvider recipient={recipient}>
token={token} <EmbedDirectTemplateClientPage
updatedAt={template.updatedAt} token={token}
documentData={template.templateDocumentData} updatedAt={template.updatedAt}
recipient={recipient} documentData={template.templateDocumentData}
fields={fields} recipient={recipient}
metadata={template.templateMeta} fields={fields}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} metadata={template.templateMeta}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
/> isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/>
</RecipientProvider>
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>
); );

View File

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

Some files were not shown because too many files have changed in this diff Show More