Compare commits

..

41 Commits

Author SHA1 Message Date
Mythie
b8310237e4 v1.7.2-rc.1 2024-10-23 13:28:54 +11:00
Ephraim Duncan
4a6238dc52 chore: show signing order when creating a document from template (#1410)
### Templates without signing order
![CleanShot 2024-10-18 at 01 27
24@2x](https://github.com/user-attachments/assets/222181e4-25a8-409b-aa8b-8452ddd32f6b)


### Template with signing order
![CleanShot 2024-10-18 at 01 26
12@2x](https://github.com/user-attachments/assets/bcee6213-20b5-44d8-90ed-881825f49756)
2024-10-23 10:20:27 +11:00
Ephraim Duncan
6fa5f63b69 fix: trigger webhook when a direct link signer signs a document (#1409) 2024-10-23 09:50:23 +11:00
Lucas Smith
c7564ba8f7 chore: enable spanish (#1417)
Enables the spanish translation of the application:


![image](https://github.com/user-attachments/assets/eacf4800-272e-4458-abcc-4d6c6f3071cf)
2024-10-23 09:01:14 +11:00
Lucas Smith
eafd7c551b feat: add team user management endpoints to api (#1416)
## Description

Adds user management capabilities to our current API. Allows for adding,
removing, listing and updating members of a given team using a valid API
token.

## Related Issue

N/A

## Changes Made

- Added an endpoint for inviting a team member
- Added an endpoint for removing a team member
- Added an endpoint for updating a team member
- Added an endpoint for listing team members

## Testing Performed

Tests were written for this feature request
2024-10-22 22:53:31 +11:00
Lucas Smith
514edf01d3 chore: add translations (#1406) 2024-10-22 14:02:51 +11:00
Ephraim Duncan
1a73c68d07 fix: close direct link dialog when you click on save (#1412) 2024-10-21 15:33:21 +11:00
Catalin Pit
1a9dcadba5 feat: add typed signature (#1357)
Add the ability to insert typed signatures.

Once the signature field is placed on the document, a checkbox appears
in the document editor where the document owner can allow signers to add
typed signatures. Typed signatures are disabled by default.

![CleanShot 2024-09-30 at 14 57
54](https://github.com/user-attachments/assets/c388abb5-bcb1-49d0-aad8-9148c3020420)
2024-10-18 14:25:19 +11:00
Ephraim Duncan
e0c948c2ac feat: add custom font sizes to fields (#1376)
Adds custom font sizes to fields

https://github.com/user-attachments/assets/1473a4d7-8dc6-4ead-acf5-dd78be7782a0
2024-10-16 16:05:41 +11:00
Catalin Pit
0bd2760792 feat: start the work on the API reference (#1392)
`later`

## Description

<!--- Describe the changes introduced by this pull request. -->
<!--- Explain what problem it solves or what feature/fix it adds. -->

## Related Issue

<!--- If this pull request is related to a specific issue, reference it
here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->

## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes.
-->

- Change 1
- Change 2
- ...

## Testing Performed

<!--- Describe the testing that you have performed to validate these
changes. -->
<!--- Include information about test cases, testing environments, and
results. -->

- Tested feature X in scenario Y.
- Ran unit tests for component Z.
- Tested on browsers A, B, and C.
- ...

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [ ] 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.
- [ ] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include details about design decisions, potential
concerns, or anything else relevant. -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
	- Introduced comprehensive documentation for the Documenso public API.
	- Added detailed guides for the following API endpoints:
		- Uploading documents
		- Generating documents from templates
		- Adding fields to documents
- Included example payloads and response structures for better
understanding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-15 14:05:39 +03:00
Timur Ercan
abc559d923 chore: cal.com customer story article (#1396)
We got cal.com 🚀

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a new blog post detailing Cal.com's use of Documenso for
enhancing DPA and BAA processes.
- The blog outlines the challenges and solutions related to compliance
document management.
- Features images and captions to illustrate key points and improve
engagement.

- **Documentation**
- Added insights into Cal.com's journey as an open-source scheduling
solution and their commitment to transparency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-11 16:01:57 +02:00
David Nguyen
9ffdbe9c81 fix: improve lingui configuration (#1388)
## Description

Currently merge conflicts arise due to the compiled JS and PO
translation files.

This PR is a rework on how we handle extracting and compiling
translations to streamline PRs and merging branches.

## Changes Made

- Remove compiled translation files from being committed
- Extract and compile translations only on build
- Extract will still occur when commits land on main to sync and pull
new translations with Crowdin
2024-10-09 14:13:52 +11:00
Ephraim Duncan
2c1a18bafc fix: stacked avatar colors (#1361) 2024-10-09 12:25:56 +11:00
Mythie
a2db5e9642 chore: update changelog 2024-10-09 12:23:38 +11:00
Lucas Smith
4ec9dc78c1 chore: add translations (#1359) 2024-10-09 10:55:21 +11:00
Mythie
faf2bd5384 v1.7.2-rc.0 2024-10-08 21:56:44 +11:00
Catalin Pit
d40ed94b74 feat: highlight problematic fields (#1330) 2024-10-08 21:55:20 +11:00
Ephraim Duncan
cd3d9b701b fix: external id null for documents created from templates (#1362) 2024-10-08 21:45:16 +11:00
Catalin Pit
e40f47a73c feat: search documents by name or recipient name or recipient email (#1384) 2024-10-08 21:44:02 +11:00
David Nguyen
64ea4a6f9f chore: add translation contribution docs (#1379) 2024-10-08 14:05:55 +11:00
Catalin Pit
18115e95d7 feat: add recipient email in activity log (#1386) 2024-10-08 14:05:12 +11:00
Catalin Pit
e736261056 fix: show the full count of documents (#1382)
![doc-count](https://github.com/user-attachments/assets/aad4fe92-e2d8-4b78-ac93-5f6ada73b03a)

A client requested it, and it makes sense showing the full count.

This is how it was before.

![CleanShot 2024-10-04 at 08 47
16@2x](https://github.com/user-attachments/assets/bd4c97a5-1805-4faa-bae7-feeb932ed614)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Updated document status tab counts to display actual numbers without
capping at 99 or using '+' symbols.

- **Bug Fixes**
- Improved clarity and accuracy of document status counts in the user
interface.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-04 16:07:20 +10:00
Ephraim Duncan
2e57da7549 chore: open page data update (#1380) 2024-10-04 13:14:54 +10:00
Timur Ercan
574454db0a chore: Go Fork Yourself blog article (#1375)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new blog post titled "Go Fork Yourself," discussing the
philosophy of open-source software and the significance of forking
within the OSS community, along with real-world examples and an
invitation for reader engagement.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-03 14:25:32 +02:00
Ephraim Duncan
f05b670d93 fix: carousel slide change handling and video reset (#1364) 2024-10-03 10:52:43 +10:00
Ephraim Duncan
318149fbf3 chore: field fonts (#1350)
Before
![CleanShot 2024-09-16 at 12 25
44](https://github.com/user-attachments/assets/9ca7672d-b132-4c24-80b0-03fa13822e50)


After
![CleanShot 2024-09-16 at 12 24
07](https://github.com/user-attachments/assets/9e17b025-8064-4151-a9e2-817108b8da2a)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a new font style for signature fields, enhancing visual
distinction.
	- Increased text size for signature fields to improve prominence.

- **Bug Fixes**
- Adjusted the text size for signature display on larger screens for
better visual hierarchy.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-02 17:45:16 +10:00
Catalin Pit
5f19dcf25c fix: dateformat bug (#1372)
## Description

It used the wrong property for finding the document's dateFormat in the
`DATE_FORMATS` array.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Improved date format selection in the settings form to ensure accurate
formatting based on value.
- Default timezone now automatically set to the user's local timezone
for better user experience.

- **Bug Fixes**
- Corrected initialization of the timezone field to enhance form
accuracy.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-10-02 17:42:15 +10:00
Timur Ercan
c99cf4b848 chore: prisma customer story on blog (#1366)
prisma customer story on blog

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a customer story blog post detailing why Prisma chose
Documenso for their signing needs, highlighting four key reasons.
- Added author information and metadata for enhanced content engagement.
- Included links to additional resources and social media for reader
interaction.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-09-27 18:35:50 +02:00
David Nguyen
18ec40f6af fix: set lang cookie expiry (#1365)
## Description

Currently the language cookie is set to session, so restarting browser
will reset it.

This sets the expiry for two years.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced language preference functionality with extended cookie
lifespan for improved user experience.
  
- **Bug Fixes**
	- Resolved issues related to cookie expiration for language settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-09-25 13:10:24 +10:00
David Nguyen
ddee8a8272 feat: allow editing pending documents (#1346)
## Description

Adds the ability for the document owner to edit recipients and their
fields after the document has been sent.

A recipient can only be updated or deleted if:
- The recipient has not inserted any fields
- Has not completed the document

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

- **New Features**
- Added new localization messages to clarify user actions regarding
document signing.
  - Enhanced French translations for improved user interaction.

- **Improvements**
- Updated localization strings in German and English for clearer
feedback on signer and recipient statuses.
- Improved overall structure of localization files for better
maintainability.

- **Dependency Updates**
- Upgraded `next-axiom` and `remeda` libraries to their latest versions,
potentially enhancing performance and stability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Mythie <me@lucasjamessmith.me>
2024-09-20 13:58:21 +10:00
David Nguyen
efb2bc94ab feat: add french (#1355)
## Description

Add initial French translations

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for the French language, enhancing accessibility for
French-speaking users.
- Introduced localized French messages for various application
functionalities, improving user experience.
  
- **Bug Fixes**
- Minor formatting updates in French translation files to remove
extraneous newline characters.

- **Chores**
- Updated line references in French translation files to maintain
alignment with code changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: github-actions <github-actions@documenso.com>
2024-09-19 19:37:17 +10:00
Lucas Smith
97ee69e7a0 chore: add translations (#1354)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: github-actions <github-actions@documenso.com>
2024-09-19 16:17:16 +10:00
Mythie
3da344fc5f v1.7.1-rc.3 2024-09-19 13:55:35 +10:00
Mythie
404ca3202f chore: update action auth 2024-09-19 13:45:39 +10:00
David Nguyen
c043fa9c06 fix: add check for invalid locales (#1353)
## Description

Currently invalid or missing `accept-language` headers will cause issues
rendering Plural components since we do not validate them.

This adds a check to try filter out invalid locales.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced locale filtering process to ensure only valid locales are
processed.
  
- **Bug Fixes**
- Improved data integrity by preventing invalid locales from affecting
application functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-09-19 09:58:59 +10:00
Mythie
9852e8971f v1.7.1-rc.2 2024-09-18 11:05:19 +10:00
Mythie
5091112e4b fix: dont nullify externalId if not passed to update document settings 2024-09-18 11:00:48 +10:00
Ephraim Duncan
e76f732990 fix: completed signing page layout (#1349) 2024-09-18 10:54:00 +10:00
Ephraim Duncan
b7c3deb6cd chore: smaller text in signature pad (#1351) 2024-09-18 10:44:12 +10:00
Lucas Smith
08114f7b97 chore: add translations (#1327) 2024-09-18 10:43:43 +10:00
Catalin Pit
6e368cc333 chore: add document visibility section (#1352) 2024-09-18 02:41:57 +10:00
142 changed files with 17230 additions and 2188 deletions

View File

@@ -1,38 +0,0 @@
# Extract and compile translations for all PRs.
name: 'Extract and compile translations'
on:
workflow_call:
pull_request:
branches: ['main']
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
extract_translations:
name: Extract and compile translations
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
- uses: ./.github/actions/node-install
- name: Extract and compile translations
run: |
npm run translate:extract
npm run translate:compile
- name: Check and commit any files created
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@documenso.com'
git add packages/lib/translations
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)

View File

@@ -25,10 +25,8 @@ jobs:
- uses: ./.github/actions/node-install
- name: Extract and compile translations
run: |
npm run translate:extract
npm run translate:compile
- name: Extract translations
run: npm run translate:extract
- name: Check and commit any files created
run: |

View File

@@ -13,9 +13,4 @@ node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/marketing/public/"
echo "Extract and compile translations"
npm run translate:extract
npm run translate:compile
git add "$MONOREPO_ROOT/packages/lib/translations/"
npx lint-staged

View File

@@ -0,0 +1,4 @@
{
"index": "Getting Started",
"contributing-translations": "Contributing Translations"
}

View File

@@ -0,0 +1,69 @@
---
title: Contributing Translations
description: Learn how to contribute translations to Documenso and become part of our community.
---
import { Callout, Steps } from 'nextra/components';
# Contributing Translations
We are always open for help with translations! Currently we utilise AI to generate the initial translations for new languages, which are then improved over time by our awesome community.
If you are looking for development notes on translations, you can find them [here](/developers/local-development/translations).
<Callout type="info">
Contributions are made through GitHub Pull Requests, so you will need a GitHub account to
contribute.
</Callout>
## Overview
We store our translations in PO files, which are located in our GitHub repository [here](https://github.com/documenso/documenso/tree/main/packages/lib/translations).
The translation files are organized into folders represented by their respective language codes (`en` for English, `de` for German, etc). Each language folder contains three PO files:
1. `web.po`: Translations for the web application
2. `marketing.po`: Translations for the marketing application
3. `common.po`: Shared translations between web and marketing
Each PO file contains translations which look like this:
```po
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
```
- `msgid`: The original text in English (never edit this manually)
- `msgstr`: The translated text in the target language
<Callout type="warning">
Notice the `<0>` tags? These represent HTML elements and must remain in both the `msgid` and `msgstr`. Make sure to translate the content between these tags while keeping the tags intact.
</Callout>
## How to Contribute
### Updating Existing Translations
1. Fork the repository.
2. Navigate to the appropriate language folder.
3. Open the PO file you want to update (web.po, marketing.po, or common.po).
4. Make your changes, ensuring you follow the PO file format.
5. Commit your changes with a message such as `chore: update German translations`
6. Create a Pull Request.
### Adding a New Language
If you want to add translations for a language that doesn't exist yet:
1. Create an issue in our GitHub repository requesting the addition of the new language.
2. Wait for our team to review and approve the request.
3. Once approved, we will set up the necessary files and kickstart the translations with AI to provide initial coverage.
## Need Help?
<Callout type="info">
If you have any questions, hop into our [Discord](https://documen.so/discord) and ask us directly!
</Callout>
Thank you for helping make Documenso more accessible to users around the world!

View File

@@ -1,5 +1,5 @@
---
title: Contributing Guide
title: Getting started
description: Learn how to contribute to Documenso and become part of our community.
---

View File

@@ -0,0 +1,507 @@
---
title: API Reference
description: Reference documentation for the Documenso public API.
---
import { Callout, Steps } from 'nextra/components';
# API Reference
The Swagger UI for the API is available at [/api/v1/openapi](https://app.documenso.com/api/v1/openapi). This page provides detailed information about the API endpoints, request and response formats, and authentication requirements.
## Upload a Document
Uploading a document to your Documenso account requires a two-step process.
<Steps>
### Create Document
First, you need to make a `POST` request to the `/api/v1/documents` endpoint, which takes a JSON payload with the following fields:
```json
{
"title": "string",
"externalId": "string",
"recipients": [
{
"name": "string",
"email": "user@example.com",
"role": "SIGNER",
"signingOrder": 0
}
],
"meta": {
"subject": "string",
"message": "string",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "string",
"signingOrder": "PARALLEL"
},
"authOptions": {
"globalAccessAuth": "ACCOUNT",
"globalActionAuth": "ACCOUNT"
},
"formValues": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
}
}
```
- `title` _(required)_ - This represents the document's title.
- `externalId` - This is an optional field that you can use to store an external identifier for the document. This can be useful for tracking the document in your system.
- `recipients` _(required)_ - This is an array of recipient objects. Each recipient object has the following fields:
- `name` - The name of the recipient.
- `email` - The email address of the recipient.
- `role` - The role of the recipient. See the [available roles](/users/signing-documents#roles).
- `signingOrder` - The order in which the recipient should sign the document. This is an integer value starting from 0.
- `meta` - This object contains additional metadata for the document. It has the following fields:
- `subject` - The subject of the email that will be sent to the recipients.
- `message` - The message of the email that will be sent to the recipients.
- `timezone` - The timezone in which the document should be signed.
- `dateFormat` - The date format that should be used in the document.
- `redirectUrl` - The URL to which the user should be redirected after signing the document.
- `signingOrder` - The signing order for the document. This can be either `SEQUENTIAL` or `PARALLEL`.
- `authOptions` - This object contains authentication options for the document. It has the following fields:
- `globalAccessAuth` - The authentication level required to access the document. This can be either `ACCOUNT` or `null`.
- If the document is set to `ACCOUNT`, all recipients must authenticate with their Documenso account to access it.
- The document can be accessed without a Documenso account if it's set to `null`.
- `globalActionAuth` - The authentication level required to perform actions on the document. This can be `ACCOUNT`, `PASSKEY`, `TWO_FACTOR_AUTH`, or `null`.
- If the document is set to `ACCOUNT`, all recipients must authenticate with their Documenso account to perform actions on the document.
- If it's set to `PASSKEY`, all recipients must have the passkey active to perform actions on the document.
- If it's set to `TWO_FACTOR_AUTH`, all recipients must have the two-factor authentication active to perform actions on the document.
- If it's set to `null`, all the recipients can perform actions on the document without any authentication.
- `formValues` - This object contains additional form values for the document. This property only works with native PDF fields and accepts three types: number, text and boolean.
<Callout type="info">
The `globalActionAuth` property is only available for Enterprise accounts.
</Callout>
Here's an example of the JSON payload for uploading a document:
```json
{
"title": "my-document.pdf",
"externalId": "12345",
"recipients": [
{
"name": "Alex Blake",
"email": "alexblake@email.com",
"role": "SIGNER",
"signingOrder": 1
},
{
"name": "Ash Drew",
"email": "ashdrew@email.com",
"role": "SIGNER",
"signingOrder": 0
}
],
"meta": {
"subject": "Sign the document",
"message": "Hey there, please sign this document.",
"timezone": "Europe/London",
"dateFormat": "Day, Month Year",
"redirectUrl": "https://mysite.com/welcome",
"signingOrder": "SEQUENTIAL"
},
"authOptions": {
"globalAccessAuth": "ACCOUNT",
"globalActionAuth": "PASSKEY"
}
}
```
### Upload to S3
A successful API call to the `/api/v1/documents` endpoint returns a JSON response containing the upload URL, document ID, and recipient information.
The upload URL is a pre-signed S3 URL that you can use to upload the document to the Documenso (or your) S3 bucket. You need to make a `PUT` request to this URL to upload the document.
```json
{
"uploadUrl": "https://<url>/<bucket-name>/<id>/my-document.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=<credentials>&X-Amz-Date=<date>&X-Amz-Expires=3600&X-Amz-Signature=<signature>&X-Amz-SignedHeaders=host&x-id=PutObject",
"documentId": 51,
"recipients": [
{
"recipientId": 11,
"name": "Alex Blake",
"email": "alexblake@email.com",
"token": "<unique-signer-token>",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/<unique-signer-token>"
},
{
"recipientId": 12,
"name": "Ash Drew",
"email": "ashdrew@email.com",
"token": "<unique-signer-token>",
"role": "SIGNER",
"signingOrder": 0,
"signingUrl": "https://app.documenso.com/sign/<unique-signer-token>"
}
]
}
```
When you make the `PUT` request to the pre-signed URL, you need to include the document file you want to upload. The image below shows how to upload a document to the S3 bucket via Postman.
![Upload document to S3](/api-reference/upload-document-to-s3.webp)
Here's an example of how to upload a document using cURL:
```bash
curl --location --request PUT 'https://<url>/<bucket-name>/<id>/my-document.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=<credentials>&X-Amz-Date=<date>&X-Amz-Expires=3600&X-Amz-Signature=<signature>&X-Amz-SignedHeaders=host&x-id=PutObject' \
--form '=@"/Users/my-user/Documents/documenso.pdf"'
```
Once the document is successfully uploaded, you can access it in your Documenso account dashboard. The screenshot below shows the document that was uploaded via the API.
![Uploaded Document](/api-reference/document-uploaded-to-documenso-via-api.webp)
</Steps>
## Generate Document From Template
Documenso allows you to generate documents from templates. This is useful when you have a standard document format you want to reuse.
The API endpoint for generating a document from a template is `/api/v1/templates/{templateId}/generate-document`, and it takes a JSON payload with the following fields:
```json
{
"title": "string",
"externalId": "string",
"recipients": [
{
"id": 0,
"name": "string",
"email": "user@example.com",
"signingOrder": 0
}
],
"meta": {
"subject": "string",
"message": "string",
"timezone": "string",
"dateFormat": "string",
"redirectUrl": "string",
"signingOrder": "PARALLEL"
},
"authOptions": {
"globalAccessAuth": "ACCOUNT",
"globalActionAuth": "ACCOUNT"
},
"formValues": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
}
}
```
The JSON payload is identical to the payload for uploading a document, so you can read more about the fields in the [Create Document](/developers/public-api/reference#create-document) step. For this API endpoint, the `recipients` property is required.
<Steps>
### Grab the Template ID
The first step is to retrieve the template ID from the Documenso dashboard. You can find the template ID in the URL by navigating to the template details page.
![Template ID](/api-reference/documenso-template-id.webp)
In this case, the template ID is "99999".
### Retrieve the Recipient(s) ID(s)
Once you have the template ID, the next step involves retrieving the ID(s) of the recipient(s) from the template. You can do this by making a GET request to `/api/v1/templates/{template-id}`.
A successful response looks as follows:
```json
{
"id": 0,
"externalId": "string",
"type": "PUBLIC",
"title": "string",
"userId": 0,
"teamId": 0,
"templateDocumentDataId": "string",
"createdAt": "2024-10-11T08:46:58.247Z",
"updatedAt": "2024-10-11T08:46:58.247Z",
"templateMeta": {
"id": "string",
"subject": "string",
"message": "string",
"timezone": "string",
"dateFormat": "string",
"templateId": 0,
"redirectUrl": "string",
"signingOrder": "PARALLEL"
},
"directLink": {
"token": "string",
"enabled": true
},
"templateDocumentData": {
"id": "string",
"type": "S3_PATH",
"data": "string"
},
"Field": [
{
"id": 0,
"recipientId": 0,
"type": "SIGNATURE",
"page": 0,
"positionX": "string",
"positionY": "string",
"width": "string",
"height": "string"
}
],
"Recipient": [
{
"id": 0,
"email": "user@example.com",
"name": "string",
"signingOrder": 0,
"authOptions": "string",
"role": "CC"
}
]
}
```
You'll need the recipient(s) ID(s) for the next step.
### Generate the Document
To generate a document from the template, you need to make a POST request to the `/api/v1/templates/{template-id}/generate-document` endpoint.
At the minimum, you must provide the `recipients` array in the JSON payload. Here's an example of the JSON payload:
```json
{
"recipients": [
{
"id": 0,
"name": "Ash Drew",
"email": "ashdrew@email.com",
"signingOrder": 0
}
]
}
```
Filling the `recipients` array with the corresponding recipient for each template placeholder recipient is recommended. For example, if the template has two placeholders, you should provide at least two recipients in the `recipients` array. Otherwise, the document will be sent to inexistent recipients such as `<recipient.1@documenso.com>`. However, the recipients can always be edited via the API or the web app.
A successful response will contain the document ID and recipient(s) information.
```json
{
"documentId": 999,
"recipients": [
{
"recipientId": 0,
"name": "Ash Drew",
"email": "ashdrew@email.com",
"token": "<signing-token>",
"role": "SIGNER",
"signingOrder": null,
"signingUrl": "https://app.documenso.com/sign/<signing-token>"
}
]
}
```
You can now access the document in your Documenso account dashboard. The screenshot below shows the document that was generated from the template.
![Generated Document](/api-reference/document-generated-from-template.webp)
</Steps>
## Add Fields to Document
The API allows you to add fields to a document via the `/api/v1/documents/{documentId}/fields` endpoint. This is useful when you want to add fields to a document before sending it to recipients.
To add fields to a document, you need to make a `POST` request with a JSON payload containing the field(s) information.
```json
{
"recipientId": 0,
"type": "SIGNATURE",
"pageNumber": 0,
"pageX": 0,
"pageY": 0,
"pageWidth": 0,
"pageHeight": 0,
"fieldMeta": {
"label": "string",
"placeholder": "string",
"required": true,
"readOnly": true,
"type": "text",
"text": "string",
"characterLimit": 0
}
}
// or
[
{
"recipientId": 0,
"type": "SIGNATURE",
"pageNumber": 0,
"pageX": 0,
"pageY": 0,
"pageWidth": 0,
"pageHeight": 0
},
{
"recipientId": 0,
"type": "TEXT",
"pageNumber": 0,
"pageX": 0,
"pageY": 0,
"pageWidth": 0,
"pageHeight": 0,
"fieldMeta": {
"label": "string",
"placeholder": "string",
"required": true,
"readOnly": true,
"type": "text",
"text": "string",
"characterLimit": 0
}
}
]
```
<Callout type="info">This endpoint accepts either one field or an array of fields.</Callout>
Before adding fields to a document, you need each recipient's ID. If the document already has recipients, you can query the document to retrieve the recipient's details. If the document has no recipients, you need to add a recipient via the UI or API before adding a field.
<Steps>
### Retrieve the Recipient(s) ID(s)
Perform a `GET` request to the `/api/v1/documents/{id}` to retrieve the details of a specific document, including the recipient's information.
An example response would look like this:
```json
{
"id": 137,
"externalId": null,
"userId": 3,
"teamId": null,
"title": "documenso.pdf",
"status": "DRAFT",
"documentDataId": "<document-data-id>",
"createdAt": "2024-10-11T12:29:12.725Z",
"updatedAt": "2024-10-11T12:29:12.725Z",
"completedAt": null,
"recipients": [
{
"id": 55,
"documentId": 137,
"email": "ashdrew@email.com",
"name": "Ash Drew",
"role": "SIGNER",
"signingOrder": null,
"token": "<signing-token>",
"signedAt": null,
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT",
"signingUrl": "https://app.documenso.com/sign/<signing-token>"
}
]
}
```
From this response, you'll only need the recipient ID, which is `55` in this case.
### (OR) Add a Recipient
If the document doesn't already have recipient(s), you can add recipient(s) via the API. Make a `POST` request to the `/api/v1/documents/{documentId}/recipients` endpoint with the recipient information. This endpoint takes the following JSON payload:
```json
{
"name": "string",
"email": "user@example.com",
"role": "SIGNER",
"signingOrder": 0,
"authOptions": {
"actionAuth": "ACCOUNT"
}
}
```
<Callout type="info">The `authOptions` property is only available for Enterprise accounts.</Callout>
Here's an example of the JSON payload for adding a recipient:
```json
{
"name": "Ash Drew",
"email": "ashdrew@email.com",
"role": "SIGNER",
"signingOrder": 0
}
```
A successful request will return a JSON response with the newly added recipient. You can now use the recipient ID to add fields to the document.
### Add Field(s)
Now you can make a `POST` request to the `/api/v1/documents/{documentId}/fields` endpoint with the field(s) information. Here's an example:
```json
[
{
"recipientId": 55,
"type": "SIGNATURE",
"pageNumber": 1,
"pageX": 50,
"pageY": 20,
"pageWidth": 25,
"pageHeight": 5
},
{
"recipientId": 55,
"type": "TEXT",
"pageNumber": 1,
"pageX": 20,
"pageY": 50,
"pageWidth": 30,
"pageHeight": 7.5,
"fieldMeta": {
"label": "Address",
"placeholder": "32 New York Street, 41241",
"required": true,
"readOnly": false,
"type": "text",
"text": "32 New York Street, 41241",
"characterLimit": 40
}
}
]
```
<Callout type="info">
The `text` field represents the default value of the field. If the user doesn't provide any other
value, this is the value that will be used to sign the field.
</Callout>
A successful request will return a JSON response with the newly added fields. The image below illustrates the fields added to the document via the API.
![A screenshot of the document in the Documenso editor](/api-reference/fields-added-via-api.webp)
</Steps>

View File

@@ -10,6 +10,7 @@
"signing-documents": "Signing Documents",
"templates": "Templates",
"direct-links": "Direct Signing Links",
"document-visibility": "Document Visibility",
"-- Legal Overview": {
"type": "separator",
"title": "Legal Overview"

View File

@@ -0,0 +1,18 @@
---
title: Document Visibility
description: Learn how to control the visibility of your team documents.
---
# Team's Document Visibility
By default, all documents created in a team are visible to all team members. However, you can control the visibility of your documents by changing the document's visibility settings.
To set the visibility of a document, click on the **Document visibility** dropdown in the document's settings panel.
![A screenshot of the Documenso's document editor page where you can update the document visibility](/document-visibility-settings.webp)
The document visibility can be set to one of the following options:
- **Everyone** - The document is visible to all team members.
- **Managers and above** - The document is visible to people with the role of Manager or above.
- **Admin only** - The document is only visible to the team's admins.

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,82 @@
---
title: Cal.com Chooses Documenso for DPA and BAA Scalability and Compliance
description: Learn how Cal.com scales their Data Processing Agreement (DPA) and Business Associate Agreement (BAA) processes with Documensos open source platform as they grow.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-10-11
tags:
- Customer Story
- Open Startup
- Open Source
---
<figure>
<MdxNextImage
src="/blog/cal2.png"
width="1260"
height="630"
alt="Scheduling Infrastructure for Everyone"
/>
<figcaption className="text-center">
Scheduling Infrastructure for Everyone.
</figcaption>
</figure>
TL;DR: Cal.com uses Documensos template direct links to facilitate low-friction compliance paperwork, enhancing scalability and user experience.
## Cal.com The Most Public Private Company
[Cal.com](Cal.com) is an open source company that needs no introduction. Founded in 2021 by Bailey Pumfleet and Peer Richelsen, it quickly evolved from an open source alternative to the widespread but limited scheduling platform Calendly into the internets most beloved scheduling solution. Starting with just two founders, Cal.com has grown into a team of 22, facilitating millions of bookings per year for its ever-growing user and customer base.
Their commitment to transparency is evident as they follow the [open startup movement](https://cal.com/open), opening up not only their source code but also providing insights into their business operations. Their Commercial Open Source Software (COSS) model, combining a company and an open source project, has inspired a whole cohort of startups joining the space—not least of which is Documenso.
## The Need
At this point, Cal.com serves customers of all sizes, from single users to large enterprises. To provide the best product for their customers, they are certified for SOC 2, HIPAA, GDPR, and many other compliance regulations. One challenge that comes with this is the increasing number of waivers that need to be signed when onboarding customers. Business Associate Agreements (BAAs) and Data Processing Agreements (DPAs) are two of the more commonly known examples. To get these signed with minimal effort for both sides, they were looking for a solution to handle these at scale.
> We love open source.
— Peer Richelsen, Co-Founder, Cal.com
Being an open source company, they also prefer open source in their vendors—for both the shared philosophy and the higher level of trust. The goal was to integrate signing into the checkout process as seamlessly as possible.
## The Solution
<figure>
<MdxNextImage
src="/blog/cal.png"
width="1260"
height="630"
alt="Cal.com direct link template to sign a DPA"
/>
<figcaption className="text-center">
Sign a DPA with Cal by clicking a link anytime.
</figcaption>
</figure>
Documenso offers exactly this solution through direct link templates, enabling Cal.com to:
- Provide Immediate Access: Customers can access and sign necessary compliance documents through direct links at any time.
- Enhance User Experience: Users are immediately forwarded to onboarding after signing.
- Ensure Easy Access: The documents are stored within the companys team account, allowing easy access for anyone who needs them.
Direct Link templates can also easily be embedded, using the [Documenso widget](https://documen.so/embedded). Embedding anywhere, pre-Filling the templates and notfiying the compliance team at certain point of the flow are a few of the many option the team now has in continously enhanceing their onboard and compliance UX.
Read more about our direct link templates here: [Direct Link Signing](https://docs.documenso.com/users/direct-links).
## The Journey
Initially, Cal.coms team approached the new solution with skepticism. As Bailey reflected:
> We were intrigued but skeptical at first, as we put a lot of thought into compliance and doing things right. Documensos documentation and support showcased how their direct link templates could meet our needs while being highly compliant.
This experience highlights Documensos trust philosophy. We strive to be transparent in everything we do and let people judge for themselves. It also shows that we neither want nor get trust by default. Doing things right is a conversation worth having, especially in a space as opaque as digital signatures. It goes without saying that the whole team is hyped to have Cal.com on board and yet another open source company joining the open signing movement 🚀
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
Best from Hamburg\
Timur

View File

@@ -0,0 +1,85 @@
---
title: 'Customer Story Prisma: 4 Reasons why Prisma chose Documenso for Signatures'
description: We are happy to welcome Prisma, another OSS company, as a customer. Read here why they choose us.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-09-26
tags:
- Prisma
- Customer Story
- Open Source
---
<figure>
<MdxNextImage
src="/blog/prisma.png"
width="1200"
height="675"
alt="Primsa Landing Page We simplify database migration, connection pooling, database queries, and readable data models."
/>
<figcaption className="text-center">
Prisma uses Documenso for collaborative team signing.
</figcaption>
</figure>
> TLDR; Prisma is now using Documenso, and [we added visibility scopes](https://docs.documenso.com/users/document-visibility)
# Prisma
Prisma is an open-source company known for its modern OSS ORM (Object-Relational Mapping) tools that simplify database interactions for developers. Their flagship product, Prisma ORM, provides a type-safe way to query databases like PostgreSQL, MySQL, and many more. With the addition of Prisma Studio, an intuitive database management interface, Prisma makes it easier and more efficient for developers to work with databases. With their new additions, Prisma Pulse and Accelerate, you can react to real-time database changes and optimize your queries. And they are completely [open source](https://github.com/prisma/prisma)!
# We choose Prisma too!
I discovered Prisma when planning the tech stack for the [first version of Documenso](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Prisma has felt natural to use since day one and has been the base of our database architecture ever since. It's great to see them develop and grow with us.
# Why they choose us
## 1. Signature Flows
Documenso signing flows are highly configurable, designed to adapt to the needs of any document signing process. Whether you're working with different roles, varying settings, or specific delivery methods, Documenso offers the flexibility to suit your requirements. You can choose to send documents via email, share a manual link, generate a link through the API, or even use a static direct link for quick access—all while ensuring a smooth signing experience.
Additionally, you can create templates to streamline and reuse common workflows, saving valuable time. Direct link templates enable users to drive the flow themselves, providing a straightforward path for signing. For a seamless experience, Documenso also allows you to embed the signing process directly into your website, ensuring an uninterrupted, integrated workflow tailored to your needs.
## 2. Modern UX
<figure>
<MdxNextImage
src="/blog/dsux.png"
width="1200"
height="675"
alt="A completed document in Documenso, ready to download."
/>
<figcaption className="text-center">
We call Documenso's design "Happy Minimalism"
</figcaption>
</figure>
Weve crafted Documenso with a sleek, modern interface that makes it incredibly easy to use. Whether youre signing documents, managing workflows, or fine-tuning settings, its intuitive design allows you to accomplish tasks quickly and effortlessly. More than just powerful, Documenso is a pleasure to navigate—designed to be accessible to everyone, no matter their level of tech experience.
## 3. Teams
### Teamwork Makes the Dream Work
Documenso makes teamwork a breeze with its team management features. You can easily set up and organize teams, making it simple to share and manage documents and workflows together. This is a lifesaver for larger organizations or teams spread across different departments, ensuring everyone stays in sync and on track. Different visibility scopes ensure private documents stay private and others are shared for easy collaboration.
### Document Visibility
Collaboration within a team often demands different levels of access to documents. For instance, the Documenso team at Prisma needed a way to set custom visibility on some documents while keeping others accessible to everyone. To address this need, we introduced role-based visibility scopes. This feature allows teams to manage documents more effectively. They can make certain documents visible only to managers or, in special cases, restricted to admins. This ensures sensitive information stays protected while general documents remain accessible to those who need them.
Learn more about visibility scopes and [how they can benefit your team here](https://docs.documenso.com/users/document-visibility).
## 4. OSS!
As you might know, we are open-source! This means you can peek under the hood, tweak things to your liking, and even contribute to making the platform better. We love the community-driven aspect of open-source, and it aligns perfectly with our goal to keep improving and innovating with input from our users.
So, whether you're looking to streamline your document workflows or just need a solid, reliable platform, Documenso has got your back. And we're thrilled to serve another OSS company and help make the space more open.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
Best from Hamburg\
Timur

View File

@@ -0,0 +1,86 @@
---
title: Go Fork Yourself
description: Curious about our take on open-source and code forking? Discover why we see forking not as a threat but as a vital part of the Open Source ecosystem.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-10-03
tags:
- Culture
- Open Startup
- Open Source
---
> TLDR; At Documenso, we see OSS as co-owned by all. Forking—collaborative or not—is part of the open-source spirit.
## Freedom vs. Ownership
Recently, there has been a lot of debate on the subject of forks and the usage of OSS IP (Open Source Software Intellectual Property). While I mostly aim to stay out of these controversies (as there is no “winning”), I wanted to take this opportunity to share my views on IP and forking culture here at Documenso. I dont presume this is the ideal path, but for me, its the only path that makes sense.
What these issues show foremost, in my opinion, is that the concept of Open Source is still evolving. I have heard many say, “Open Source is clearly defined” and that there is no ambiguity anymore. That may be true on the legal side, but there are vast differences in how these rules are interpreted and lived out. Here are a few questions to illustrate the point:
1. Is it okay to use an open-source project without ever giving back?
2. Is it okay to fork (some might say copy) an OSS product and build something on top of it?
3. Are we morally obliged to fight those who provide different answers to these questions than we do?
## Embracing Forks and Collaboration
Since starting Documenso, Ive thought a lot about what it actually means to be Open Source for us. So far, it has been about openness in working with everyone, from contributors to customers and sharing our work transparently. For this, we have been richly rewarded with attention and reach. This collaborative give-and-take is what people commonly associate with being Open Source, and it seems ideal.
Yet, there are the questions mentioned above. And while these may be contentious, my take is straightforward:
1. Yes.
2. Yes.
3. No.
I say this because, to me, the principles of Open Source are rooted in freedom and collaboration. That means allowing others to use, improve, or even compete with what youve built without feeling possessive over the code. The beauty of Open Source lies in its openness—its ability to be forked, reused, and adapted by anyone.
You may answer these questions differently for your own reasons. One thing Ive found lacking in the discourse is the fact that Open Source is still being treated as socially proprietary. If its under an open-source license, you can fork it and try to improve upon the original, and theres nothing wrong with that. The same is true for closed-source startups. Yet in Open Source, theres a notion that its somehow “dirty,” even though the license explicitly allows it.
## Forking in Action: Real-World Examples
When the team behind **Node.js** disagreed with its governance and pace of development, they forked the project to create **io.js**. This wasnt seen as dirty but as a necessary push for change. In fact, the fork resulted in positive changes—better community governance and faster development—which eventually led to the merge of the two projects under the Node.js Foundation. It shows that forking can be a catalyst for improvement, not just competition.
## The Misconception of “Exploitative” Usage
However, sometimes forks dont merge back but still bring positive change. A good example is **Jenkins**, which was forked from **Hudson** over disagreements in governance after Oracle acquired Sun Microsystems. Jenkins quickly overtook Hudson in terms of community support, development, and innovation. Rather than being seen as a hostile move, the fork enabled Jenkins to become a thriving project, better aligned with the open-source ethos of collaboration and transparency. It emphasizes that forking isnt inherently exploitative; it can simply be a way to realize a projects full potential.
And then theres **MariaDB**, a fork of **MySQL**. After Oracle acquired MySQL, many in the community feared the projects open-source nature could be compromised. The fork preserved its spirit, and MariaDB has since grown to become a popular and thriving database. Its a reminder that sometimes, forking is not just acceptable—its necessary to uphold the values and freedoms of open-source software.
<figure>
<MdxNextImage
src="/blog/owncode.jpeg"
width="1200"
height="675"
alt="Meme: If everyone owns the code, no one does."
/>
<figcaption className="text-center">
Funny Meme to drive the point home.
</figcaption>
</figure>
My view is that the code is not “your” code, just as Documensos code is not “our” code. Its been co-owned by the world ever since we published the repo under AGPL V3. That is the whole point. Its finally not owned by anyone (cue the “everyone/no one” meme). Open Source is for everyone, even competitors. Yet, we are still treating the licenses as extensions of the old, proprietary world and defending perceived injustices based on that model.
> Side Note: Full compliance with all license and other legal rules is a given here.
## Documensos Approach: Co-Ownership and Community
So, if you want to fork Documenso and build a business on it, you can. Whether thats a cool thing to do is another matter. Whether you do a better job than us is also another matter (you wont). But if you do, Ill be the first to join. But why not join us from the start since you already have the upside? We exist because we believe this to be the best way forward—not because we force it.
## The Bigger Picture: Open-Source as Progress
Ive also thought a lot about question #3. I understand the impulse to fight anyone who doesnt appreciate this collaborative approach, but there is no part of this model that backs that up. You are free to “exploit” as long as its in a way that adds value. The fallacy is in considering someone else using the OSS part for their business as treason, which its not. Its the whole point.
While some might say this is theoretical and that reality is different, this is the version of Open Source on which we are building Documenso. The point here is that OSS companies must be resilient to handle forking and competition; without this resilience, an open source driven economy cant thrive. The focus on freedom and collaboration means being prepared for forks and challenges as part of the growth, not as threats.
Of course, all of this applies to Documenso, the OSS project, not Documenso Inc., the company, which is very much a privately owned, for-profit entity. However, since the goal is to scale Documenso to the entire world, there is plenty of room to see everyone as co-owners of the Open Source project rather than as competitors. In the end, Open Source is about progress through freedom. If you dont like how we run things, go fork yourself and hold us accountable. We dont own this; we just happened to start it.
> Since this article is open source as well, you are free to fork it and change it here: [https://documen.so/repo](https://documen.so/repo)
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
Best from Hamburg\
Timur

View File

@@ -8,6 +8,61 @@ Check out what's new in the latest version and read our thoughts on it. For more
---
# Documenso v1.7.1: Signing order and document visibility
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:
## 🌟 Key New Features
### 1. Signing Order
Specify the sequence in which recipients sign your documents. This ensures a structured signing process, particularly useful for complex agreements or hierarchical approvals.
<video
src="/changelog/signing-order-demo.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
### 2. Document Visibility
Manage who can view your documents and when. This feature offers greater privacy and flexibility in your document sharing workflows.
<video
src="/changelog/document-visibility-demo.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
## 🔧 Other Improvements
- Added language switcher for easier language selection
- Support for smaller field bounds in documents
- Improved select field UX
- Enhanced template functionality for advanced fields
- Added authOptions to the API
- Various UI refinements and bug fixes
## 💡 Recent Features
Don't forget about these powerful features from our recent v1.7.0 release:
- Embedded Signing Experience
- Copy and Paste Fields
- Customizable Signature Colors
## 👏 Thank You
As always, we're grateful for our community's contributions and feedback. Your input continues to shape Documenso into the leading open-source document signing solution.
We're eager to see how these new features enhance your document workflows. Enjoy exploring Documenso v1.7.1!
---
# Documenso v1.7.0: Embedded Signing, Copy and Paste, and More
We're thrilled to announce the release of Documenso v1.7.0, packed with exciting new features and improvements that enhance document signing flexibility, user experience, and global accessibility.

View File

@@ -1,11 +1,11 @@
{
"name": "@documenso/marketing",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"build": "turbo run translate:extract && turbo run translate:compile && next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",
@@ -34,7 +34,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-axiom": "^1.5.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

View File

@@ -19,10 +19,10 @@ export const TEAM_MEMBERS = [
},
{
name: 'Ephraim Atta-Duncan',
role: 'Software Engineer - Intern',
salary: 15_000,
role: 'Software Engineer - I',
salary: 60_000,
location: 'Ghana',
engagement: msg`Part-Time`,
engagement: msg`Full-Time`,
joinDate: 'June 6th, 2023',
},
{

View File

@@ -108,14 +108,21 @@ export const Carousel = () => {
return;
}
setSelectedIndex(emblaApi.selectedScrollSnap());
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
const newIndex = emblaApi.selectedScrollSnap();
setSelectedIndex(newIndex);
emblaThumbsApi.scrollTo(newIndex);
resetProgress();
const currentVideo = videoRefs.current[newIndex];
if (currentVideo) {
currentVideo.currentTime = 0;
}
// moduleResolution: bundler breaks this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const autoplay = emblaApi.plugins()?.autoplay as unknown as AutoplayType | undefined;
const autoplay = emblaApi?.plugins()?.autoplay as unknown as AutoplayType | undefined;
if (autoplay) {
autoplay.reset();

View File

@@ -1,11 +1,11 @@
{
"name": "@documenso/web",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"build": "turbo run translate:extract && turbo run translate:compile && next build",
"start": "next start",
"lint": "next lint",
"e2e:prepare": "next build && next start",
@@ -37,7 +37,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-axiom": "^1.5.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
@@ -53,7 +53,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^1.27.1",
"remeda": "^2.12.1",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",

View File

@@ -112,6 +112,24 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: updateTypedSignature } =
trpc.document.updateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({
...(oldData || initialDocument),
...newData,
id: Number(newData.id),
}),
);
},
});
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newRecipients) => {
@@ -258,6 +276,11 @@ export const EditDocumentForm = ({
fields: data.fields,
});
await updateTypedSignature({
documentId: document.id,
typedSignatureEnabled: data.typedSignatureEnabled,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@@ -387,6 +410,7 @@ export const EditDocumentForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
teamId={team?.id}
/>

View File

@@ -16,6 +16,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
@@ -25,16 +26,17 @@ import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export type DocumentsPageViewProps = {
export interface DocumentsPageViewProps {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
search?: string;
};
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
};
}
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
@@ -44,6 +46,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const search = searchParams.search || '';
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
@@ -52,6 +55,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const getStatOptions: GetStatsInput = {
user,
period,
search,
};
if (team) {
@@ -79,6 +83,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
perPage,
period,
senderIds,
search,
});
const getTabHref = (value: typeof status) => {
@@ -135,10 +140,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
@@ -151,6 +153,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={search} />
</div>
</div>
</div>

View File

@@ -144,6 +144,7 @@ export const TemplatesDataTable = ({
<div className="flex items-center gap-x-4">
<UseTemplateDialog
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>

View File

@@ -434,12 +434,14 @@ export const TemplateDirectLinkDialog = ({
<Button
type="button"
loading={isTogglingTemplateAccess}
onClick={async () =>
toggleTemplateDirectLink({
onClick={async () => {
await toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
})
}
}).catch((e) => null);
onOpenChange(false);
}}
>
<Trans>Save</Trans>
</Button>

View File

@@ -15,7 +15,9 @@ import {
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -51,6 +53,7 @@ const ZAddRecipientsForNewDocumentSchema = z
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
@@ -86,6 +89,7 @@ type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDoc
export type UseTemplateDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
documentRootPath: string;
};
@@ -94,6 +98,7 @@ export function UseTemplateDialog({
recipients,
documentRootPath,
templateId,
templateSigningOrder,
}: UseTemplateDialogProps) {
const router = useRouter();
@@ -108,21 +113,24 @@ export function UseTemplateDialog({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
};
}),
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
});
@@ -203,6 +211,33 @@ export function UseTemplateDialog({
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
<FormField
control={form.control}
name={`recipients.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('w-20', {
'mt-8': index === 0,
})}
>
<FormControl>
<Input
{...field}
disabled
className="items-center justify-center"
value={
field.value?.toString() ||
recipients[index]?.signingOrder?.toString()
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`recipients.${index}.email`}

View File

@@ -204,25 +204,29 @@ export default async function CompletedSigningPage({
</div>
</div>
{canSignUp && (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
<Trans>
Create your account and start using state-of-the-art document signing.
</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Trans>Go Back Home</Trans>
</Link>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600">
<Trans>Go Back Home</Trans>
</Link>
)}
</div>
</div>
<PollUntilDocumentCompleted document={document} />

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -25,22 +25,19 @@ export const DocumentActionAuthAccount = ({
}: DocumentActionAuthAccountProps) => {
const { recipient } = useRequiredDocumentAuthContext();
const [isSigningOut, setIsSigningOut] = useState(false);
const router = useRouter();
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
await signOut({
redirect: false,
});
await signOut({
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
});
router.push(`/signin#email=${email}`);
} catch {
setIsSigningOut(false);

View File

@@ -9,9 +9,10 @@ import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@@ -25,7 +26,7 @@ import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog';
export type SigningFormProps = {
document: Document;
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
@@ -196,6 +197,7 @@ export const SigningForm = ({
onChange={(value) => {
setSignature(value);
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>

View File

@@ -31,12 +31,12 @@ import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean;
};
export const SignatureField = ({
@@ -44,6 +44,7 @@ export const SignatureField = ({
recipient,
onSignField,
onUnsignField,
typedSignatureEnabled,
}: SignatureFieldProps) => {
const router = useRouter();
@@ -92,14 +93,12 @@ export const SignatureField = ({
return true;
};
/**
* When the user clicks the sign button in the dialog where they enter their signature.
*/
const onDialogSignClick = () => {
setShowSignatureModal(false);
setProvidedSignature(localSignature);
if (!localSignature) {
return;
}
@@ -109,7 +108,6 @@ export const SignatureField = ({
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
try {
const value = signature || providedSignature;
@@ -231,11 +229,11 @@ export const SignatureField = ({
id="signature"
className="border-border mt-2 h-44 w-full rounded-md border"
onChange={(value) => setLocalSignature(value)}
allowTypedSignature={typedSignatureEnabled}
/>
</div>
<SigningDisclosure />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
@@ -249,7 +247,6 @@ export const SignatureField = ({
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"

View File

@@ -2,12 +2,12 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -20,24 +20,19 @@ export const SigningAuthPageView = ({ email, emailHasAccount }: SigningAuthPageV
const { _ } = useLingui();
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
const router = useRouter();
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
const encryptedEmail = await encryptSecondaryData({
data: email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
await signOut({
redirect: false,
});
await signOut({
callbackUrl: emailHasAccount
? `/signin?email=${encodeURIComponent(encryptedEmail)}`
: `/signup?email=${encodeURIComponent(encryptedEmail)}`,
});
router.push(emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`);
} catch {
toast({
title: _(msg`Something went wrong`),

View File

@@ -128,7 +128,7 @@ export const SigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]', type === 'Checkbox' ? 'group' : '')}>
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button

View File

@@ -112,7 +112,12 @@ export const SigningPageView = ({
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
<SignatureField
key={field.id}
field={field}
recipient={recipient}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField key={field.id} field={field} recipient={recipient} />

View File

@@ -1,6 +1,5 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { env } from 'next-runtime-env';
@@ -11,7 +10,6 @@ import {
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
@@ -19,24 +17,11 @@ export const metadata: Metadata = {
title: 'Sign In',
};
type SignInPageProps = {
searchParams: {
email?: string;
};
};
export default function SignInPage({ searchParams }: SignInPageProps) {
export default function SignInPage() {
setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signin');
}
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
@@ -50,7 +35,6 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
<hr className="-mx-6 my-4" />
<SignInForm
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
oidcProviderLabel={OIDC_PROVIDER_LABEL}

View File

@@ -5,7 +5,6 @@ import { env } from 'next-runtime-env';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
@@ -13,13 +12,7 @@ export const metadata: Metadata = {
title: 'Sign Up',
};
type SignUpPageProps = {
searchParams: {
email?: string;
};
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
export default function SignUpPage() {
setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
@@ -28,17 +21,9 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
redirect('/signin');
}
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signup');
}
return (
<SignUpFormV2
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/>

View File

@@ -1,9 +1,11 @@
'use client';
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
@@ -29,24 +31,26 @@ export const StackAvatarsWithTooltip = ({
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting',
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
const openedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'opened',
(recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED,
);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'completed',
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'unsigned',
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const sortedRecipients = useMemo(() => recipients.sort((a, b) => a.id - b.id), [recipients]);
return (
<PopoverHover
trigger={children || <StackAvatars recipients={recipients} />}
trigger={children || <StackAvatars recipients={sortedRecipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
side: position,
@@ -65,7 +69,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import {
getExtraRecipientsType,
getRecipientType,
} from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
@@ -13,20 +16,27 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => {
const first = index === 0 ? true : false;
const first = index === 0;
const lastItemText =
index === itemsToRender.length - 1 && remainingItems > 0
? `+${remainingItems + 1}`
: undefined;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
});

View File

@@ -0,0 +1,41 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(initialValue);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('search', term);
} else {
params.delete('search');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
useEffect(() => {
handleSearch(searchTerm);
}, [debouncedSearchTerm]);
return (
<Input
type="search"
placeholder="Search documents..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
);
};

View File

@@ -89,7 +89,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
{field.Signature?.typedSignature}
</p>
),
@@ -122,7 +122,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
{field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl lg:text-3xl':
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}

View File

@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@@ -307,6 +307,18 @@ export const SignInForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<Form {...form}>
<form

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
@@ -203,6 +203,18 @@ export const SignUpFormV2 = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">

View File

@@ -58,6 +58,8 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
RUN npm ci
# Then copy all the source code (as it changes more often)

106
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"workspaces": [
"apps/*",
"packages/*"
@@ -80,7 +80,7 @@
},
"apps/marketing": {
"name": "@documenso/marketing",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@@ -103,7 +103,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-axiom": "^1.5.1",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@@ -410,6 +410,23 @@
}
}
},
"apps/marketing/node_modules/next-axiom": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.5.1.tgz",
"integrity": "sha512-sWxIzuJOex48ugMDlXWzvGvDGv5YHZ3w8gLZbUQ/Yml7oy5jcCItJNws9D0qmASirp2e5/BnvHxs44+9CO0GAQ==",
"license": "MIT",
"dependencies": {
"use-deep-compare": "^1.2.1",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"next": ">=14.0",
"react": ">=18.0.0"
}
},
"apps/marketing/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -424,7 +441,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",
@@ -449,7 +466,7 @@
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-axiom": "^1.5.1",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
@@ -465,7 +482,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^1.27.1",
"remeda": "^2.12.1",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
@@ -493,6 +510,23 @@
"integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==",
"dev": true
},
"apps/web/node_modules/next-axiom": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.5.1.tgz",
"integrity": "sha512-sWxIzuJOex48ugMDlXWzvGvDGv5YHZ3w8gLZbUQ/Yml7oy5jcCItJNws9D0qmASirp2e5/BnvHxs44+9CO0GAQ==",
"license": "MIT",
"dependencies": {
"use-deep-compare": "^1.2.1",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"next": ">=14.0",
"react": ">=18.0.0"
}
},
"apps/web/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -24150,22 +24184,6 @@
}
}
},
"node_modules/next-axiom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz",
"integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==",
"dependencies": {
"remeda": "^1.29.0",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"next": ">=13.4",
"react": ">=18.0.0"
}
},
"node_modules/next-contentlayer": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz",
@@ -30130,9 +30148,25 @@
}
},
"node_modules/remeda": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-1.29.0.tgz",
"integrity": "sha512-M3LQ14KtMdQ1879lj/kKji3zBk158s7Rwg963mEkTfQFMxnKrIEAMxJfo/+0sp/+uGgN/KMVU2MBA4LNjqf8YQ=="
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.12.1.tgz",
"integrity": "sha512-hKFAbxbQe8PMd4+CYO1DYCrCbcZsUSa7e21g7+4co91GBy7BD+Ub6JdaLy76yPOp7PCPTAXRz/9NXtZ9w15jbg==",
"license": "MIT",
"dependencies": {
"type-fest": "^4.26.1"
}
},
"node_modules/remeda/node_modules/type-fest": {
"version": "4.26.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz",
"integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/remote-git-tags": {
"version": "3.0.0",
@@ -34195,6 +34229,18 @@
}
}
},
"node_modules/use-deep-compare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/use-deep-compare/-/use-deep-compare-1.3.0.tgz",
"integrity": "sha512-94iG+dEdEP/Sl3WWde+w9StIunlV8Dgj+vkt5wTwMoFQLaijiEZSXXy8KtcStpmEDtIptRJiNeD4ACTtVvnIKA==",
"license": "MIT",
"dependencies": {
"dequal": "2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/use-memo-one": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
@@ -36751,7 +36797,7 @@
"pg": "^8.11.3",
"playwright": "1.43.0",
"react": "^18",
"remeda": "^1.27.1",
"remeda": "^2.12.1",
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
@@ -37050,6 +37096,12 @@
"node": ">=6"
}
},
"packages/ui/node_modules/remeda": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-1.61.0.tgz",
"integrity": "sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==",
"license": "MIT"
},
"packages/ui/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.7.1-rc.1",
"version": "1.7.2-rc.1",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",

View File

@@ -12,10 +12,12 @@ import {
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZFindTeamMembersResponseSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZGetTemplatesQuerySchema,
ZInviteTeamMemberMutationSchema,
ZNoBodyMutationSchema,
ZResendDocumentForSigningMutationSchema,
ZSendDocumentForSigningMutationSchema,
@@ -26,13 +28,17 @@ import {
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulGetTemplateResponseSchema,
ZSuccessfulGetTemplatesResponseSchema,
ZSuccessfulInviteTeamMemberResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulRemoveTeamMemberResponseSchema,
ZSuccessfulResendDocumentResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
ZUpdateFieldMutationSchema,
ZUpdateRecipientMutationSchema,
ZUpdateTeamMemberMutationSchema,
} from './schema';
const c = initContract();
@@ -273,6 +279,61 @@ export const ApiContractV1 = c.router(
},
summary: 'Delete a field from a document',
},
findTeamMembers: {
method: 'GET',
path: '/api/v1/team/:id/members',
responses: {
200: ZFindTeamMembersResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'List team members',
},
inviteTeamMember: {
method: 'POST',
path: '/api/v1/team/:id/members/invite',
body: ZInviteTeamMemberMutationSchema,
responses: {
200: ZSuccessfulInviteTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Invite a member to a team',
},
updateTeamMember: {
method: 'PUT',
path: '/api/v1/team/:id/members/:memberId',
body: ZUpdateTeamMemberMutationSchema,
responses: {
200: ZSuccessfulUpdateTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a team member',
},
removeTeamMember: {
method: 'DELETE',
path: '/api/v1/team/:id/members/:memberId',
body: null,
responses: {
200: ZSuccessfulRemoveTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Remove a member from a team',
},
},
{
baseHeaders: ZAuthorizationHeadersSchema,

View File

@@ -26,6 +26,8 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites';
import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members';
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
@@ -49,7 +51,12 @@ import {
} from '@documenso/lib/universal/upload/server-actions';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
@@ -302,7 +309,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: body.authOptions,
data: {
...body.authOptions,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@@ -1277,4 +1286,270 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
},
};
}),
findTeamMembers: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId } = args.params;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const members = await prisma.teamMember.findMany({
where: {
teamId: team.id,
},
include: {
user: true,
},
});
return {
status: 200,
body: {
members: members.map((member) => ({
id: member.id,
email: member.user.email,
role: member.role,
})),
},
};
}),
inviteTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId } = args.params;
const { email, role } = args.body;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const hasAlreadyBeenInvited = await prisma.teamMember.findFirst({
where: {
teamId: team.id,
user: {
email,
},
},
});
if (hasAlreadyBeenInvited) {
return {
status: 400,
body: {
message: 'This user has already been invited to the team',
},
};
}
await createTeamMemberInvites({
userId: user.id,
userName: user.name ?? '',
teamId: team.id,
invitations: [
{
email,
role,
},
],
});
return {
status: 200,
body: {
message: 'An invite has been sent to the member',
},
};
}),
updateTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId, memberId } = args.params;
const { role } = args.body;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const member = await prisma.teamMember.findFirst({
where: {
id: Number(memberId),
teamId: team.id,
},
});
if (!member) {
return {
status: 404,
body: {
message: 'The provided member id does not exist.',
},
};
}
const updatedMember = await prisma.teamMember.update({
where: {
id: member.id,
},
data: {
role,
},
include: {
user: true,
},
});
return {
status: 200,
body: {
id: updatedMember.id,
email: updatedMember.user.email,
role: updatedMember.role,
},
};
}),
removeTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId, memberId } = args.params;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const member = await prisma.teamMember.findFirst({
where: {
id: Number(memberId),
teamId: Number(teamId),
},
include: {
user: true,
},
});
if (!member) {
return {
status: 404,
body: {
message: 'Member not found',
},
};
}
if (team.ownerUserId === member.userId) {
return {
status: 403,
body: {
message: 'You cannot remove the owner of the team',
},
};
}
if (member.userId === user.id) {
return {
status: 403,
body: {
message: 'You cannot remove yourself from the team',
},
};
}
await deleteTeamMembers({
userId: user.id,
teamId: team.id,
teamMemberIds: [member.id],
});
return {
status: 200,
body: {
id: member.id,
email: member.user.email,
role: member.role,
},
};
}),
});

View File

@@ -19,6 +19,7 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
@@ -532,3 +533,41 @@ export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});
export const ZFindTeamMembersResponseSchema = z.object({
members: z.array(
z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
),
});
export const ZInviteTeamMemberMutationSchema = z.object({
email: z
.string()
.email()
.transform((email) => email.toLowerCase()),
role: z.nativeEnum(TeamMemberRole).optional().default(TeamMemberRole.MEMBER),
});
export const ZSuccessfulInviteTeamMemberResponseSchema = z.object({
message: z.string(),
});
export const ZUpdateTeamMemberMutationSchema = z.object({
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulUpdateTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulRemoveTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});

View File

@@ -0,0 +1,278 @@
import { expect, test } from '@playwright/test';
import {
ZFindTeamMembersResponseSchema,
ZSuccessfulInviteTeamMemberResponseSchema,
ZSuccessfulRemoveTeamMemberResponseSchema,
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
} from '@documenso/api/v1/schema';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Team API', () => {
test('findTeamMembers: should list team members', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const ownerMember = team.members.find((member) => member.userId === team.owner.id)!;
// Should not be undefined
expect(ownerMember).toBeTruthy();
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.get(`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const data = await response.json();
const parsed = ZFindTeamMembersResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.members).toHaveLength(4); // Owner + 3 members
expect(safeData!.members[0]).toHaveProperty('id');
expect(safeData!.members[0]).toHaveProperty('email');
expect(safeData!.members[0]).toHaveProperty('role');
expect(safeData!.members).toContainEqual({
id: ownerMember.id,
email: ownerMember.user.email,
role: ownerMember.role,
});
});
test('inviteTeamMember: should invite a new team member', async ({ request }) => {
const team = await seedTeam();
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const newUser = await seedUser();
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/invite`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
email: newUser.email,
role: TeamMemberRole.MEMBER,
},
},
);
expect(response.ok()).toBeTruthy();
const data = await response.json();
const parsed = ZSuccessfulInviteTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.message).toBe('An invite has been sent to the member');
const invite = await prisma.teamMemberInvite.findFirst({
where: {
email: newUser.email,
teamId: team.id,
},
});
expect(invite).toBeTruthy();
});
test('updateTeamMember: should update a team member role', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Should not be undefined
expect(member).toBeTruthy();
const response = await request.put(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
role: TeamMemberRole.ADMIN,
},
},
);
expect(response.ok()).toBeTruthy();
const data = await response.json();
const parsed = ZSuccessfulUpdateTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.id).toBe(member.id);
expect(safeData!.email).toBe(member.user.email);
expect(safeData!.role).toBe(TeamMemberRole.ADMIN);
});
test('removeTeamMember: should remove a team member', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Should not be undefined
expect(member).toBeTruthy();
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(200);
const data = await response.json();
const parsed = ZSuccessfulRemoveTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.id).toBe(member.id);
expect(safeData!.email).toBe(member.user.email);
expect(safeData!.role).toBe(member.role);
const removedMemberCount = await prisma.teamMember.count({
where: {
id: member.id,
teamId: team.id,
},
});
expect(removedMemberCount).toBe(0);
});
test('removeTeamMember: should not remove team owner', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const ownerMember = team.members.find((member) => member.userId === team.owner.id)!;
// Should not be undefined
expect(ownerMember).toBeTruthy();
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${ownerMember.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(403);
const parsed = ZUnsuccessfulResponseSchema.safeParse(await response.json());
expect(parsed.success).toBeTruthy();
});
test('removeTeamMember: should not remove self', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Make our non-owner member an admin
await prisma.teamMember.update({
where: {
id: member.id,
},
data: {
role: TeamMemberRole.ADMIN,
},
});
const { token } = await createApiToken({
userId: member.userId,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(403);
const parsed = ZUnsuccessfulResponseSchema.safeParse(await response.json());
expect(parsed.success).toBeTruthy();
});
});

View File

@@ -0,0 +1,249 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test('[TEAMS]: search respects team document visibility', async ({ page }) => {
const team = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Searchable Document for Everyone',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Document for Managers',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Searchable Document for Admins',
},
},
]);
const testCases = [
{ user: adminUser, visibleDocs: 3 },
{ user: managerUser, visibleDocs: 2 },
{ user: memberUser, visibleDocs: 1 },
];
for (const { user, visibleDocs } of testCases) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Searchable');
await page.waitForURL(/search=Searchable/);
await checkDocumentTabCount(page, 'All', visibleDocs);
await apiSignout({ page });
}
});
test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamA.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: teamA.id,
visibility: 'EVERYONE',
title: 'Unique Team A Document',
},
},
{
sender: teamB.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: teamB.id,
visibility: 'EVERYONE',
title: 'Unique Team B Document',
},
},
]);
await apiSignin({
page,
email: teamAMember.email,
redirectPath: `/t/${teamA.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Unique Team B Document' })).not.toBeVisible();
await apiSignout({ page });
});
test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: null,
title: 'Personal Unique Document',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Team Unique Document',
},
},
]);
await apiSignin({
page,
email: teamMember2.email,
redirectPath: '/documents',
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
page,
}) => {
const team = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: team.owner,
recipients: [memberUser],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Recipient',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Admin Document');
await page.waitForURL(/search=Admin(%20|\+|\s)Document/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
).toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => {
const team = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
name: 'Team Member',
});
const uniqueRecipient = await seedUser();
await seedDocuments([
{
sender: team.owner,
recipients: [uniqueRecipient],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document for Unique Recipient',
},
},
]);
// Admin should see the document when searching by recipient name
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).toBeVisible();
await apiSignout({ page });
// Member should not see the document when searching by recipient name
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 0);
await expect(
page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
).not.toBeVisible();
await apiSignout({ page });
});

View File

@@ -112,7 +112,6 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await page.getByRole('switch').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
await page.getByLabel('Direct Link Signing', { exact: true }).press('Escape');
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));

View File

@@ -5,9 +5,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
"test-ui:dev": "playwright test --ui",
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
"author": "",

View File

@@ -2,7 +2,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { equals } from 'remeda';
import { isDeepEqual } from 'remeda';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
@@ -42,7 +42,7 @@ export const LimitsProvider = ({
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
if (equals(oldLimits, newLimits)) {
if (isDeepEqual(oldLimits, newLimits)) {
return oldLimits;
}

View File

@@ -0,0 +1,78 @@
import config from '@documenso/tailwind-config';
import {
Body,
Container,
Head,
Hr,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from '../components';
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
import TemplateDocumentImage from '../template-components/template-document-image';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCancelEmailTemplateProps = Partial<TemplateDocumentCancelProps>;
export const RecipientRemovedFromDocumentTemplate = ({
inviterName = 'Lucas Smith',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCancelEmailTemplateProps) => {
const previewText = `${inviterName} has removed you from the document ${documentName}.`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has removed you from the document
<br />"{documentName}"
</Text>
</Section>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default RecipientRemovedFromDocumentTemplate;

View File

@@ -1,15 +1,10 @@
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface CheckboxFieldMeta {
readOnly?: boolean;
required?: boolean;
validationRule?: string;
validationLength?: number;
}
import type { TCheckboxFieldMeta } from '../types/field-meta';
export const validateCheckboxField = (
values: string[],
fieldMeta: CheckboxFieldMeta,
fieldMeta: TCheckboxFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];

View File

@@ -1,14 +1,10 @@
interface DropdownFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { value: string }[];
defaultValue?: string;
}
import type { TDropdownFieldMeta as DropdownFieldMeta } from '../types/field-meta';
export const validateDropdownField = (
value: string | undefined,
fieldMeta: DropdownFieldMeta,
isSigningPage: boolean = false,
fontSize?: number,
): string[] => {
const errors = [];
@@ -50,5 +46,9 @@ export const validateDropdownField = (
errors.push('Duplicate values are not allowed');
}
if (fontSize && (fontSize < 8 || fontSize > 96)) {
errors.push('Font size must be between 8 and 96.');
}
return errors;
};

View File

@@ -0,0 +1,19 @@
import type {
TDateFieldMeta as DateFieldMeta,
TEmailFieldMeta as EmailFieldMeta,
TInitialsFieldMeta as InitialsFieldMeta,
TNameFieldMeta as NameFieldMeta,
} from '../types/field-meta';
export const validateFields = (
fieldMeta: DateFieldMeta | EmailFieldMeta | InitialsFieldMeta | NameFieldMeta,
): string[] => {
const errors = [];
const { fontSize } = fieldMeta;
if (fontSize && (fontSize < 8 || fontSize > 96)) {
errors.push('Font size must be between 8 and 96.');
}
return errors;
};

View File

@@ -1,12 +1,5 @@
// import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface NumberFieldMeta {
minValue?: number;
maxValue?: number;
readOnly?: boolean;
required?: boolean;
numberFormat?: string;
}
import type { TNumberFieldMeta as NumberFieldMeta } from '../types/field-meta';
export const validateNumberField = (
value: string,
@@ -15,7 +8,7 @@ export const validateNumberField = (
): string[] => {
const errors = [];
const { minValue, maxValue, readOnly, required, numberFormat } = fieldMeta || {};
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
const formatRegex: { [key: string]: RegExp } = {
'123,456,789.00': /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
@@ -63,5 +56,9 @@ export const validateNumberField = (
errors.push('A field cannot be both read-only and required');
}
if (fontSize && (fontSize < 8 || fontSize > 96)) {
errors.push('Font size must be between 8 and 96.');
}
return errors;
};

View File

@@ -1,8 +1,4 @@
interface RadioFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { checked: boolean; value: string }[];
}
import type { TRadioFieldMeta as RadioFieldMeta } from '../types/field-meta';
export const validateRadioField = (
value: string | undefined,

View File

@@ -1,8 +1,4 @@
interface TextFieldMeta {
characterLimit?: number;
readOnly?: boolean;
required?: boolean;
}
import type { TTextFieldMeta as TextFieldMeta } from '../types/field-meta';
export const validateTextField = (
value: string,
@@ -11,7 +7,7 @@ export const validateTextField = (
): string[] => {
const errors = [];
const { characterLimit, readOnly, required } = fieldMeta;
const { characterLimit, readOnly, required, fontSize } = fieldMeta;
if (required && !value && isSigningPage) {
errors.push('Value is required');
@@ -29,5 +25,9 @@ export const validateTextField = (
errors.push('A field cannot be both read-only and required');
}
if (fontSize && (fontSize < 8 || fontSize > 96)) {
errors.push('Font size must be between 8 and 96.');
}
return errors;
};

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({

View File

@@ -15,9 +15,10 @@ type SupportedLanguages = (typeof SUPPORTED_LANGUAGE_CODES)[number];
async function loadCatalog(lang: SupportedLanguages): Promise<{
[k: string]: Messages;
}> {
const { messages } = await import(
`../../translations/${lang}/${IS_APP_WEB ? 'web' : 'marketing'}.js`
);
const extension = process.env.NODE_ENV === 'development' ? 'po' : 'js';
const context = IS_APP_WEB ? 'web' : 'marketing';
const { messages } = await import(`../../translations/${lang}/${context}.${extension}`);
return {
[lang]: messages,

View File

@@ -1,12 +1,19 @@
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
WAITING = 'waiting',
UNSIGNED = 'unsigned',
}
export const getRecipientType = (recipient: Recipient) => {
if (
recipient.role === RecipientRole.CC ||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) {
return 'completed';
return RecipientStatusType.COMPLETED;
}
if (
@@ -14,12 +21,33 @@ export const getRecipientType = (recipient: Recipient) => {
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return 'opened';
return RecipientStatusType.OPENED;
}
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
return 'waiting';
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return RecipientStatusType.WAITING;
}
return 'unsigned';
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}
if (types.includes(RecipientStatusType.WAITING)) {
return RecipientStatusType.WAITING;
}
return RecipientStatusType.COMPLETED;
};

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en'] as const;
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es'] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@@ -38,4 +38,12 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
full: 'English',
short: 'en',
},
fr: {
full: 'French',
short: 'fr',
},
es: {
full: 'Spanish',
short: 'es',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;

View File

@@ -48,7 +48,7 @@
"pg": "^8.11.3",
"playwright": "1.43.0",
"react": "^18",
"remeda": "^1.27.1",
"remeda": "^2.12.1",
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",

View File

@@ -18,6 +18,7 @@ export type CreateDocumentMetaOptions = {
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
typedSignatureEnabled?: boolean;
userId: number;
requestMetadata: RequestMetadata;
};
@@ -32,6 +33,7 @@ export const upsertDocumentMeta = async ({
userId,
redirectUrl,
signingOrder,
typedSignatureEnabled,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -82,6 +84,7 @@ export const upsertDocumentMeta = async ({
documentId,
redirectUrl,
signingOrder,
typedSignatureEnabled,
},
update: {
subject,
@@ -91,6 +94,7 @@ export const upsertDocumentMeta = async ({
timezone,
redirectUrl,
signingOrder,
typedSignatureEnabled,
},
});

View File

@@ -25,6 +25,7 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
search?: string;
};
export const findDocuments = async ({
@@ -37,6 +38,7 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
@@ -92,6 +94,14 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const visibilityFilters = [
match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
@@ -188,7 +198,7 @@ export const findDocuments = async ({
}
const whereClause: Prisma.DocumentWhereInput = {
AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }],
AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }],
};
if (period) {

View File

@@ -15,9 +15,10 @@ export type GetStatsInput = {
user: User;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
};
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@@ -31,8 +32,14 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({ ...options.team, createdAt, currentUserEmail: user.email, userId: user.id })
: getCounts({ user, createdAt }));
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
})
: getCounts({ user, createdAt, search }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@@ -72,9 +79,18 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
};
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@@ -87,6 +103,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
},
}),
// Not signed counts.
@@ -105,6 +122,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
},
}),
// Has signed counts.
@@ -142,6 +160,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
},
}),
]);
@@ -155,6 +174,7 @@ type GetTeamCountsOption = {
userId: number;
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
@@ -169,6 +189,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}
: undefined;
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
@@ -220,6 +248,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
...searchFilter,
};
if (teamEmail) {

View File

@@ -93,11 +93,14 @@ export const updateDocumentSettings = async ({
}
}
const isTitleSame = data.title === document.title;
const isExternalIdSame = data.externalId === document.externalId;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame = data.visibility === document.visibility;
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
@@ -200,7 +203,7 @@ export const updateDocumentSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},

View File

@@ -1,3 +1,5 @@
import { isDeepEqual } from 'remeda';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -20,22 +22,15 @@ import {
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
fields: FieldData[];
requestMetadata?: RequestMetadata;
}
@@ -63,6 +58,9 @@ export const setFieldsForDocument = async ({
},
],
},
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
@@ -97,21 +95,36 @@ export const setFieldsForDocument = async ({
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields
.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
_persisted: existing,
};
})
.filter((field) => {
return (
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
const recipient = document.Recipient.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
}
// Check whether the existing field can be modified.
if (
existing &&
hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a field where the recipient has already interacted with the document',
);
});
}
return {
...field,
_persisted: existing,
_recipient: recipient,
};
});
const persistedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
@@ -322,3 +335,33 @@ export const setFieldsForDocument = async ({
return [...filteredFields, ...persistedFields];
};
/**
* If you change this you MUST update the `hasFieldBeenChanged` function.
*/
type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
};
const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
const currentFieldMeta = field.fieldMeta || null;
const newFieldMeta = newFieldData.fieldMeta || null;
return (
field.type !== newFieldData.type ||
field.page !== newFieldData.pageNumber ||
field.positionX.toNumber() !== newFieldData.pageX ||
field.positionY.toNumber() !== newFieldData.pageY ||
field.width.toNumber() !== newFieldData.pageWidth ||
field.height.toNumber() !== newFieldData.pageHeight ||
!isDeepEqual(currentFieldMeta, newFieldMeta)
);
};

View File

@@ -4,5 +4,8 @@ import { cookies } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/require-await
export const switchI18NLanguage = async (lang: string) => {
cookies().set('language', lang);
// Two year expiry.
const maxAge = 60 * 60 * 24 * 365 * 2;
cookies().set('language', lang, { maxAge });
};

View File

@@ -1,6 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@@ -13,7 +14,16 @@ import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
@@ -32,7 +42,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = maxFontSize;
const page = pages.at(field.page - 1);
@@ -207,16 +216,33 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}
})
.otherwise((field) => {
const fieldMetaParsers = {
[FieldType.TEXT]: ZTextFieldMeta,
[FieldType.NUMBER]: ZNumberFieldMeta,
[FieldType.DATE]: ZDateFieldMeta,
[FieldType.EMAIL]: ZEmailFieldMeta,
[FieldType.NAME]: ZNameFieldMeta,
[FieldType.INITIALS]: ZInitialsFieldMeta,
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
@@ -250,17 +276,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
return pdf;
};
export const insertFieldInPDFBytes = async (
pdf: ArrayBuffer | Uint8Array | string,
field: FieldWithSignature,
) => {
const pdfDoc = await PDFDocument.load(pdf);
await insertFieldInPDF(pdfDoc, field);
return await pdfDoc.save();
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,

View File

@@ -10,6 +10,7 @@ export async function insertTextInPDF(
positionY: number,
page = 0,
useHandwritingFont = true,
customFontSize?: number,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH());
@@ -24,7 +25,7 @@ export async function insertTextInPDF(
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = useHandwritingFont ? 50 : 15;
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };

View File

@@ -51,7 +51,7 @@ export const createApiToken = async ({
name: tokenName,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
userId,
teamId,
},
});

View File

@@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
return await prisma.apiToken.delete({
where: {
id,
userId: teamId ? null : userId,
teamId,
teamId: teamId ?? null,
},
});
};

View File

@@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
teamId: null,
},
select: {
id: true,

View File

@@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
throw new Error('Expired token');
}
if (apiToken.team) {
// Handle a silly choice from many moons ago
if (apiToken.team && !apiToken.user) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
@@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
const { user } = apiToken;
// This will never happen but we need to narrow types
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
return {
...apiToken,
user,
};
};

View File

@@ -1,4 +1,9 @@
import { createElement } from 'react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
type TRecipientActionAuthTypes,
@@ -16,20 +21,16 @@ import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientBeModified } from '../../utils/recipients';
export interface SetRecipientsForDocumentOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: {
id?: number | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
recipients: RecipientData[];
requestMetadata?: RequestMetadata;
}
@@ -59,6 +60,9 @@ export const setRecipientsForDocument = async ({
teamId: null,
}),
},
include: {
Field: true,
},
});
const user = await prisma.user.findFirstOrThrow({
@@ -116,25 +120,28 @@ export const setRecipientsForDocument = async ({
),
);
const linkedRecipients = normalizedRecipients
.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
_persisted: existing,
};
})
.filter((recipient) => {
return (
recipient._persisted?.role === RecipientRole.CC ||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
!canRecipientBeModified(existing, document.Field)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a recipient who has already interacted with the document',
);
});
}
return {
...recipient,
_persisted: existing,
};
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
@@ -268,6 +275,37 @@ export const setRecipientsForDocument = async ({
),
});
});
// Send emails to deleted recipients.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'You have been removed from a document',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// Filter out recipients that have been removed or have been updated.
@@ -284,3 +322,27 @@ export const setRecipientsForDocument = async ({
return [...filteredRecipients, ...persistedRecipients];
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id?: number | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.actionAuth !== newRecipientData.actionAuth
);
};

View File

@@ -17,6 +17,7 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
@@ -39,6 +40,7 @@ import {
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
@@ -553,6 +555,23 @@ export const createDocumentFromDirectTemplate = async ({
teamId: template.teamId || undefined,
requestMetadata,
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
} catch (err) {
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);

View File

@@ -1,8 +1,9 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { DocumentSigningOrder, Field } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
type Recipient,
RecipientRole,
SendStatus,
@@ -153,7 +154,7 @@ export const createDocumentFromTemplate = async ({
const document = await tx.document.create({
data: {
source: DocumentSource.TEMPLATE,
externalId,
externalId: externalId || template.externalId,
templateId: template.id,
userId,
teamId: template.teamId,
@@ -172,7 +173,9 @@ export const createDocumentFromTemplate = async ({
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
signingOrder:
override?.signingOrder || template.templateMeta?.signingOrder || undefined,
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
},
},
Recipient: {

View File

@@ -51,6 +51,11 @@ export const findTemplates = async ({
},
Field: true,
Recipient: true,
templateMeta: {
select: {
signingOrder: true,
},
},
directLink: {
select: {
token: true,

View File

@@ -100,7 +100,7 @@ export const updateTemplateSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
externalId: data.externalId,
type: data.type,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,

4
packages/lib/translations/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Compiled translations.
*.js

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-09-05 06:04\n"
"PO-Revision-Date: 2024-10-18 04:04\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -223,6 +223,7 @@ msgstr "Aus dem Blog"
#: apps/marketing/src/app/(marketing)/open/data.ts:9
#: apps/marketing/src/app/(marketing)/open/data.ts:17
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#: apps/marketing/src/app/(marketing)/open/data.ts:33
#: apps/marketing/src/app/(marketing)/open/data.ts:41
#: apps/marketing/src/app/(marketing)/open/data.ts:49
@@ -385,8 +386,8 @@ msgid "Our self-hosted option is great for small teams and individuals who need
msgstr "Unsere selbstgehostete Option ist ideal für kleine Teams und Einzelpersonen, die eine einfache Lösung benötigen. Sie können unser docker-basiertes Setup verwenden, um in wenigen Minuten loszulegen. Übernehmen Sie die Kontrolle mit vollständiger Anpassbarkeit und Datenhoheit."
#: apps/marketing/src/app/(marketing)/open/data.ts:25
msgid "Part-Time"
msgstr "Teilzeit"
#~ msgid "Part-Time"
#~ msgstr "Part-Time"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
@@ -431,7 +432,7 @@ msgstr "Sparen Sie $60 oder $120"
#: apps/marketing/src/components/(marketing)/i18n-switcher.tsx:47
#~ msgid "Search languages..."
#~ msgstr "Sprachen suchen..."
#~ msgstr "Search languages..."
#: apps/marketing/src/app/(marketing)/pricing/page.tsx:109
msgid "Securely. Our data centers are located in Frankfurt (Germany), giving us the best local privacy laws. We are very aware of the sensitive nature of our data and follow best practices to ensure the security and integrity of the data entrusted to us."
@@ -614,6 +615,7 @@ msgstr "Ja! Documenso wird unter der GNU AGPL V3 Open-Source-Lizenz angeboten. D
msgid "You can self-host Documenso for free or use our ready-to-use hosted version. The hosted version comes with additional support, painless scalability and more. Early adopters will get access to all features we build this year, for no additional cost! Forever! Yes, that includes multiple users per account later. If you want Documenso for your enterprise, we are happy to talk about your needs."
msgstr "Sie können Documenso kostenlos selbst hosten oder unsere sofort einsatzbereite gehostete Version nutzen. Die gehostete Version bietet zusätzlichen Support, schmerzfreie Skalierbarkeit und mehr. Frühzeitige Anwender erhalten in diesem Jahr Zugriff auf alle Funktionen, die wir entwickeln, ohne zusätzliche Kosten! Für immer! Ja, das beinhaltet später mehrere Benutzer pro Konto. Wenn Sie Documenso für Ihr Unternehmen möchten, sprechen wir gerne über Ihre Bedürfnisse."
#: apps/marketing/src/components/(marketing)/carousel.tsx:265
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Ihr Browser unterstützt das Video-Tag nicht."

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -68,16 +68,16 @@ msgstr "Add an external ID to the document. This can be used to identify the doc
msgid "Add an external ID to the template. This can be used to identify in external systems."
msgstr "Add an external ID to the template. This can be used to identify in external systems."
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:177
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:187
msgid "Add another option"
msgstr "Add another option"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:230
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:232
#: packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx:167
msgid "Add another value"
msgstr "Add another value"
#: packages/ui/primitives/document-flow/add-signers.tsx:653
#: packages/ui/primitives/document-flow/add-signers.tsx:662
msgid "Add myself"
msgstr "Add myself"
@@ -89,15 +89,15 @@ msgstr "Add Myself"
msgid "Add Placeholder Recipient"
msgstr "Add Placeholder Recipient"
#: packages/ui/primitives/document-flow/add-signers.tsx:642
#: packages/ui/primitives/document-flow/add-signers.tsx:651
msgid "Add Signer"
msgstr "Add Signer"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:70
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:73
msgid "Add text"
msgstr "Add text"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:75
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:78
msgid "Add text to the field"
msgstr "Add text to the field"
@@ -110,7 +110,7 @@ msgstr "Admin"
msgid "Advanced Options"
msgstr "Advanced Options"
#: packages/ui/primitives/document-flow/add-fields.tsx:522
#: packages/ui/primitives/document-flow/add-fields.tsx:570
#: packages/ui/primitives/template-flow/add-template-fields.tsx:402
msgid "Advanced settings"
msgstr "Advanced settings"
@@ -135,23 +135,27 @@ msgstr "Approver"
msgid "Approving"
msgstr "Approving"
#: packages/ui/primitives/signature-pad/signature-pad.tsx:276
#: packages/ui/primitives/signature-pad/signature-pad.tsx:377
msgid "Black"
msgstr "Black"
#: packages/ui/primitives/signature-pad/signature-pad.tsx:290
#: packages/ui/primitives/signature-pad/signature-pad.tsx:391
msgid "Blue"
msgstr "Blue"
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:287
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:356
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx:58
msgid "Cancel"
msgstr "Cancel"
#: packages/ui/primitives/document-flow/add-signers.tsx:194
#: packages/ui/primitives/document-flow/add-signers.tsx:193
msgid "Cannot remove signer"
msgstr "Cannot remove signer"
#: packages/ui/primitives/document-flow/add-signers.tsx:221
#~ msgid "Cannot update signer because they have already signed a field"
#~ msgstr "Cannot update signer because they have already signed a field"
#: packages/lib/constants/recipient-roles.ts:17
msgid "Cc"
msgstr "Cc"
@@ -165,16 +169,16 @@ msgstr "CC"
msgid "CC'd"
msgstr "CC'd"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:83
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:86
msgid "Character Limit"
msgstr "Character Limit"
#: packages/ui/primitives/document-flow/add-fields.tsx:944
#: packages/ui/primitives/document-flow/add-fields.tsx:1026
#: packages/ui/primitives/template-flow/add-template-fields.tsx:788
msgid "Checkbox"
msgstr "Checkbox"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:195
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:197
msgid "Checkbox values"
msgstr "Checkbox values"
@@ -182,7 +186,7 @@ msgstr "Checkbox values"
msgid "Clear filters"
msgstr "Clear filters"
#: packages/ui/primitives/signature-pad/signature-pad.tsx:310
#: packages/ui/primitives/signature-pad/signature-pad.tsx:411
msgid "Clear Signature"
msgstr "Clear Signature"
@@ -198,7 +202,7 @@ msgstr "Close"
msgid "Configure Direct Recipient"
msgstr "Configure Direct Recipient"
#: packages/ui/primitives/document-flow/add-fields.tsx:523
#: packages/ui/primitives/document-flow/add-fields.tsx:571
#: packages/ui/primitives/template-flow/add-template-fields.tsx:403
msgid "Configure the {0} field"
msgstr "Configure the {0} field"
@@ -215,7 +219,7 @@ msgstr "Copied to clipboard"
msgid "Custom Text"
msgstr "Custom Text"
#: packages/ui/primitives/document-flow/add-fields.tsx:840
#: packages/ui/primitives/document-flow/add-fields.tsx:922
#: packages/ui/primitives/template-flow/add-template-fields.tsx:684
msgid "Date"
msgstr "Date"
@@ -247,18 +251,18 @@ msgstr "Download"
msgid "Drag & drop your PDF here."
msgstr "Drag & drop your PDF here."
#: packages/ui/primitives/document-flow/add-fields.tsx:970
#: packages/ui/primitives/document-flow/add-fields.tsx:1052
#: packages/ui/primitives/template-flow/add-template-fields.tsx:814
msgid "Dropdown"
msgstr "Dropdown"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:148
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:158
msgid "Dropdown options"
msgstr "Dropdown options"
#: packages/ui/primitives/document-flow/add-fields.tsx:788
#: packages/ui/primitives/document-flow/add-fields.tsx:870
#: packages/ui/primitives/document-flow/add-signature.tsx:272
#: packages/ui/primitives/document-flow/add-signers.tsx:491
#: packages/ui/primitives/document-flow/add-signers.tsx:500
#: packages/ui/primitives/template-flow/add-template-fields.tsx:632
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:463
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:470
@@ -269,20 +273,28 @@ msgstr "Email"
msgid "Email Options"
msgstr "Email Options"
#: packages/ui/primitives/document-flow/add-fields.tsx:1117
msgid "Empty field"
msgstr "Empty field"
#: packages/lib/constants/template.ts:8
msgid "Enable Direct Link Signing"
msgstr "Enable Direct Link Signing"
#: packages/ui/primitives/document-flow/add-signers.tsx:392
#: packages/ui/primitives/document-flow/add-signers.tsx:401
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:362
msgid "Enable signing order"
msgstr "Enable signing order"
#: packages/ui/primitives/document-flow/add-fields.tsx:790
msgid "Enable Typed Signatures"
msgstr "Enable Typed Signatures"
#: packages/ui/primitives/document-password-dialog.tsx:84
msgid "Enter password"
msgstr "Enter password"
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:216
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:257
msgid "Error"
msgstr "Error"
@@ -291,26 +303,44 @@ msgstr "Error"
msgid "External ID"
msgstr "External ID"
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:217
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:258
msgid "Failed to save settings."
msgstr "Failed to save settings."
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:90
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:93
msgid "Field character limit"
msgstr "Field character limit"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:107
#: packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx:62
#: packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx:44
#: packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx:44
#: packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx:44
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:130
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:107
msgid "Field font size"
msgstr "Field font size"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:110
msgid "Field format"
msgstr "Field format"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:50
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:53
msgid "Field label"
msgstr "Field label"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:62
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:65
msgid "Field placeholder"
msgstr "Field placeholder"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx:56
#: packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx:38
#: packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx:38
#: packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx:38
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:124
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:101
msgid "Font Size"
msgstr "Font Size"
#: packages/ui/components/document/document-global-auth-action-select.tsx:64
msgid "Global recipient action authentication"
msgstr "Global recipient action authentication"
@@ -319,7 +349,7 @@ msgstr "Global recipient action authentication"
msgid "Go Back"
msgstr "Go Back"
#: packages/ui/primitives/signature-pad/signature-pad.tsx:297
#: packages/ui/primitives/signature-pad/signature-pad.tsx:398
msgid "Green"
msgstr "Green"
@@ -348,9 +378,9 @@ msgstr "I am required to receive a copy of this document"
msgid "Inherit authentication method"
msgstr "Inherit authentication method"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:64
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:69
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:45
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:67
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:72
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:48
msgid "Label"
msgstr "Label"
@@ -358,7 +388,7 @@ msgstr "Label"
msgid "Manager"
msgstr "Manager"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:168
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:188
msgid "Max"
msgstr "Max"
@@ -371,14 +401,14 @@ msgstr "Member"
msgid "Message <0>(Optional)</0>"
msgstr "Message <0>(Optional)</0>"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:156
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:176
msgid "Min"
msgstr "Min"
#: packages/ui/primitives/document-flow/add-fields.tsx:814
#: packages/ui/primitives/document-flow/add-fields.tsx:896
#: packages/ui/primitives/document-flow/add-signature.tsx:298
#: packages/ui/primitives/document-flow/add-signers.tsx:526
#: packages/ui/primitives/document-flow/add-signers.tsx:532
#: packages/ui/primitives/document-flow/add-signers.tsx:535
#: packages/ui/primitives/document-flow/add-signers.tsx:541
#: packages/ui/primitives/template-flow/add-template-fields.tsx:658
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:498
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:504
@@ -397,12 +427,12 @@ msgstr "Needs to sign"
msgid "Needs to view"
msgstr "Needs to view"
#: packages/ui/primitives/document-flow/add-fields.tsx:625
#: packages/ui/primitives/document-flow/add-fields.tsx:680
#: packages/ui/primitives/template-flow/add-template-fields.tsx:497
msgid "No recipient matching this description was found."
msgstr "No recipient matching this description was found."
#: packages/ui/primitives/document-flow/add-fields.tsx:641
#: packages/ui/primitives/document-flow/add-fields.tsx:696
#: packages/ui/primitives/template-flow/add-template-fields.tsx:513
msgid "No recipients with this role"
msgstr "No recipients with this role"
@@ -427,12 +457,12 @@ msgstr "No signature field found"
msgid "No value found."
msgstr "No value found."
#: packages/ui/primitives/document-flow/add-fields.tsx:892
#: packages/ui/primitives/document-flow/add-fields.tsx:974
#: packages/ui/primitives/template-flow/add-template-fields.tsx:736
msgid "Number"
msgstr "Number"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:100
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:103
msgid "Number format"
msgstr "Number format"
@@ -452,17 +482,17 @@ msgstr "Page {0} of {1}"
msgid "Password Required"
msgstr "Password Required"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:154
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:156
msgid "Pick a number"
msgstr "Pick a number"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:76
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:81
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:57
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:79
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:84
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:60
msgid "Placeholder"
msgstr "Placeholder"
#: packages/ui/primitives/document-flow/add-fields.tsx:918
#: packages/ui/primitives/document-flow/add-fields.tsx:1000
#: packages/ui/primitives/template-flow/add-template-fields.tsx:762
msgid "Radio"
msgstr "Radio"
@@ -471,11 +501,11 @@ msgstr "Radio"
msgid "Radio values"
msgstr "Radio values"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:184
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:137
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:136
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:186
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:147
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:156
#: packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx:122
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:114
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:133
msgid "Read only"
msgstr "Read only"
@@ -489,7 +519,7 @@ msgstr "Receives copy"
msgid "Recipient action authentication"
msgstr "Recipient action authentication"
#: packages/ui/primitives/signature-pad/signature-pad.tsx:283
#: packages/ui/primitives/signature-pad/signature-pad.tsx:384
msgid "Red"
msgstr "Red"
@@ -498,15 +528,15 @@ msgstr "Red"
msgid "Redirect URL"
msgstr "Redirect URL"
#: packages/ui/primitives/document-flow/add-fields.tsx:1008
#: packages/ui/primitives/document-flow/add-fields.tsx:1104
msgid "Remove"
msgstr "Remove"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:174
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:127
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:126
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:176
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:137
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:146
#: packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx:112
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:104
#: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx:123
msgid "Required field"
msgstr "Required field"
@@ -514,7 +544,7 @@ msgstr "Required field"
msgid "Rows per page"
msgstr "Rows per page"
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:286
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx:355
msgid "Save"
msgstr "Save"
@@ -526,7 +556,7 @@ msgstr "Save Template"
msgid "Search languages..."
msgstr "Search languages..."
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:105
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:115
msgid "Select"
msgstr "Select"
@@ -534,11 +564,11 @@ msgstr "Select"
msgid "Select an option"
msgstr "Select an option"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:137
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:139
msgid "Select at least"
msgstr "Select at least"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:95
#: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx:105
msgid "Select default option"
msgstr "Select default option"
@@ -560,7 +590,7 @@ msgstr "Share Signature Card"
msgid "Share the Link"
msgstr "Share the Link"
#: packages/ui/primitives/document-flow/add-signers.tsx:671
#: packages/ui/primitives/document-flow/add-signers.tsx:680
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx:655
msgid "Show advanced settings"
msgstr "Show advanced settings"
@@ -569,7 +599,7 @@ msgstr "Show advanced settings"
msgid "Sign"
msgstr "Sign"
#: packages/ui/primitives/document-flow/add-fields.tsx:736
#: packages/ui/primitives/document-flow/add-fields.tsx:818
#: packages/ui/primitives/document-flow/add-signature.tsx:323
#: packages/ui/primitives/document-flow/field-icon.tsx:52
#: packages/ui/primitives/template-flow/add-template-fields.tsx:580
@@ -617,7 +647,7 @@ msgstr "Submit"
msgid "Template title"
msgstr "Template title"
#: packages/ui/primitives/document-flow/add-fields.tsx:866
#: packages/ui/primitives/document-flow/add-fields.tsx:948
#: packages/ui/primitives/template-flow/add-template-fields.tsx:710
msgid "Text"
msgstr "Text"
@@ -678,7 +708,7 @@ msgstr "The signer's name"
msgid "This can be overriden by setting the authentication requirements directly on each recipient in the next step."
msgstr "This can be overriden by setting the authentication requirements directly on each recipient in the next step."
#: packages/ui/primitives/document-flow/add-fields.tsx:697
#: packages/ui/primitives/document-flow/add-fields.tsx:752
msgid "This document has already been sent to this recipient. You can no longer edit this recipient."
msgstr "This document has already been sent to this recipient. You can no longer edit this recipient."
@@ -690,9 +720,17 @@ msgstr "This document is password protected. Please enter the password to view t
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
#: packages/ui/primitives/document-flow/add-signers.tsx:195
msgid "This signer has already received the document."
msgstr "This signer has already received the document."
#: packages/ui/primitives/document-flow/add-fields.tsx:1084
msgid "This recipient can no longer be modified as they have signed a field, or completed the document."
msgstr "This recipient can no longer be modified as they have signed a field, or completed the document."
#: packages/ui/primitives/document-flow/add-signers.tsx:165
#~ msgid "This signer has already received the document."
#~ msgstr "This signer has already received the document."
#: packages/ui/primitives/document-flow/add-signers.tsx:194
msgid "This signer has already signed the document."
msgstr "This signer has already signed the document."
#: packages/ui/components/recipient/recipient-action-auth-select.tsx:48
msgid "This will override any global settings."
@@ -707,7 +745,7 @@ msgstr "Time Zone"
msgid "Title"
msgstr "Title"
#: packages/ui/primitives/document-flow/add-fields.tsx:983
#: packages/ui/primitives/document-flow/add-fields.tsx:1067
#: packages/ui/primitives/template-flow/add-template-fields.tsx:828
msgid "To proceed further, please set at least one value for the {0} field."
msgstr "To proceed further, please set at least one value for the {0} field."
@@ -728,13 +766,13 @@ msgstr "Upgrade"
msgid "Upload Template Document"
msgstr "Upload Template Document"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:130
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:147
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx:132
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:167
msgid "Validation"
msgstr "Validation"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:88
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:93
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:91
#: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx:96
msgid "Value"
msgstr "Value"

File diff suppressed because one or more lines are too long

View File

@@ -218,6 +218,7 @@ msgstr "From the blog"
#: apps/marketing/src/app/(marketing)/open/data.ts:9
#: apps/marketing/src/app/(marketing)/open/data.ts:17
#: apps/marketing/src/app/(marketing)/open/data.ts:25
#: apps/marketing/src/app/(marketing)/open/data.ts:33
#: apps/marketing/src/app/(marketing)/open/data.ts:41
#: apps/marketing/src/app/(marketing)/open/data.ts:49
@@ -380,8 +381,8 @@ msgid "Our self-hosted option is great for small teams and individuals who need
msgstr "Our self-hosted option is great for small teams and individuals who need a simple solution. You can use our docker based setup to get started in minutes. Take control with full customizability and data ownership."
#: apps/marketing/src/app/(marketing)/open/data.ts:25
msgid "Part-Time"
msgstr "Part-Time"
#~ msgid "Part-Time"
#~ msgstr "Part-Time"
#: apps/marketing/src/components/(marketing)/pricing-table.tsx:151
msgid "Premium Profile Name"
@@ -609,6 +610,6 @@ msgstr "Yes! Documenso is offered under the GNU AGPL V3 open source license. Thi
msgid "You can self-host Documenso for free or use our ready-to-use hosted version. The hosted version comes with additional support, painless scalability and more. Early adopters will get access to all features we build this year, for no additional cost! Forever! Yes, that includes multiple users per account later. If you want Documenso for your enterprise, we are happy to talk about your needs."
msgstr "You can self-host Documenso for free or use our ready-to-use hosted version. The hosted version comes with additional support, painless scalability and more. Early adopters will get access to all features we build this year, for no additional cost! Forever! Yes, that includes multiple users per account later. If you want Documenso for your enterprise, we are happy to talk about your needs."
#: apps/marketing/src/components/(marketing)/carousel.tsx:265
#: apps/marketing/src/components/(marketing)/carousel.tsx:272
msgid "Your browser does not support the video tag."
msgstr "Your browser does not support the video tag."

File diff suppressed because one or more lines are too long

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