Compare commits

...

59 Commits

Author SHA1 Message Date
0eff336175 v1.8.0-rc.4 2024-11-19 16:44:25 +11:00
9bdd5c31cc fix: sort recipients for template with signing order (#1468) 2024-11-18 15:54:51 +07:00
57ad7c150b chore: add translations (#1474) 2024-11-18 08:40:25 +11:00
b0829e6cdf v1.8.0-rc.3 2024-11-16 09:23:05 +11:00
08a446fefd feat: support windows for 2fa tokens (#1478)
## Description

When using 2fa enabled authentication on direct templates we run into an
issue where a 2fa token has been attached to a field but it's submitted
at a later point.

To better facilitate this we have introduced the ability to have a
window of valid tokens.

This won't affect other signing methods since tokens are verified
immediately after they're entered.

## Related Issue

N/A

## Changes Made

- Updated our validate2FAToken method to use a window based approach
rather than the default verify method.

## Testing Performed

- Created a series of tokens and tested upon different intervals and
windows to confirm functionality works as expected.
2024-11-16 09:17:45 +11:00
f15f9ecdd1 chore: update docs 2024-11-15 21:47:22 +07:00
979e3f3e71 fix: always allow access to billing (#1476) 2024-11-15 21:34:39 +07:00
876803b5db fix: handle team invites being accepted but not added 2024-11-15 13:27:36 +11:00
1c87cb1e0d v1.8.0-rc.2 2024-11-15 01:56:22 +11:00
5398026b80 feat: signature rejection (#1472)
## Description

Adds support for rejecting a given document informing the document
owner.

Flows for resolving a rejection don't currently exist so it's up to the
document owner to reach out to the recipient and work out a way to move
forward via a new document or offline agreement.

## Related Issue


## Changes Made

- Added new rejection properties to the recipient schema
- Added API endpoints to support rejection
- Added email templates for notifying the document owner and recipient
- Added a dialog on the signing page to start the rejection flow.

## Testing Performed

- Manually tested the flow end to end
- Automated tests are planned
2024-11-14 21:37:42 +11:00
f2439abbc9 chore: update docs 2024-11-12 23:12:13 +07:00
5a6e031c90 chore: add translations (#1463) 2024-11-12 15:50:22 +07:00
bcc3b70335 fix: errors moving fields (#1429) 2024-11-12 15:49:31 +07:00
5a26610a01 fix: update publish workfow to only tag stable versions latest (#1405)
## Description

This pull request introduces modifications to the GitHub Actions
workflow to ensure that the `latest` Docker tag is only pushed for
stable releases. It prevents the `latest` tag from being assigned to
release candidates (`-rc`), beta versions, or other pre-release tags.
This enhancement improves version management by keeping the `latest` tag
reserved exclusively for fully stable versions (e.g., `1.0.0`).

## Related Issue

Fixes #1404

## Changes Made

- Added a conditional check to verify if the release version follows the
format `X.Y.Z` (stable release format).
- Updated the Docker manifest creation steps to only push the `latest`
tag if the version is a stable full release.
- Modified both DockerHub and GitHub Container Registry push steps to
reflect the new versioning conditions.
2024-11-12 17:37:34 +11:00
5d7a979baf chore: add translations (#1461) 2024-11-12 13:00:56 +07:00
552825b79e chore: extract translations 2024-11-12 12:37:34 +07:00
786566bae4 fix: certificate translations (#1460)
## Description

Currently certificate translations on production sometimes does not show
the required language.

This could not be replicated when creating certificates on staging
(Browserless.io) and local development (Chromium), which means this fix
ultimately cannot be tested unless on live.

This is an attempt to fix it by isolating the certificate generation
into it's own context, and applying a cookie to define the required
language.

This fix is based on the assumption that there is some sort of error
which pushes the certificate to be generated on the client side, which
ultimately will render in English due to constraints on nextjs.

## Changes Made

- Apply language into cookie instead purely dynamically on SSR
- Minor unrelated fixes

## Testing Performed

Tested to ensure certificates could still be generated
2024-11-12 15:26:14 +11:00
cb23357b42 fix: document url in the command menu search (#1453) 2024-11-12 00:12:15 +07:00
0078162159 chore: project babel (#1420)
blogpost babel
2024-11-08 16:42:25 +01:00
19e23d8ef3 v1.8.0-rc.0 2024-11-08 23:09:56 +11:00
e3b7ec82a3 chore: add translations (#1451) 2024-11-08 23:06:57 +11:00
23a0537648 feat: add global settings for teams (#1391)
## Description

This PR introduces global settings for teams. At the moment, it allows
team admins to configure the following:
* The default visibility of the documents uploaded to the team account
* Whether to include the document owner (sender) details when sending
emails to the recipients.

### Include Sender Details

If the Sender Details setting is enabled, the emails sent by the team
will include the sender's name:

> "Example User" on behalf of "Example Team" has invited you to sign
"document.pdf"

Otherwise, the email will say:

> "Example Team" has invited you to sign "document.pdf"

### Default Document Visibility

This new option allows users to set the default visibility for the
documents uploaded to the team account. It can have the following
values:
* Everyone
* Manager and above
* Admins only

If the default document visibility isn't set, the document will be set
to the role of the user who created the document:
* If a user with the "User" role creates a document, the document's
visibility is set to "Everyone".
* Manager role -> "Manager and above"
* Admin role -> "Admins only"

Otherwise, if there is a default document visibility value, it uses that
value.

#### Gotcha

To avoid issues, the `document owner` and the `recipient` can access the
document irrespective of their role. For example:
* If a team member with the role "Member" uploads a document and the
default document visibility is "Admins", only the document owner and
admins can access the document.
  * Similar to the other scenarios.

* If an admin uploads a document and the default document visibility is
"Admins", the recipient can access the document.

* The admins have access to all the documents.
* Managers have access to documents with the visibility set to
"Everyone" and "Manager and above"
* Members have access only to the documents with the visibility set to
"Everyone".

## Testing Performed

Tested it locally.
2024-11-08 22:50:49 +11:00
f6bcf921d5 feat: add document distribution setting (#1437)
Add a document distribution setting which will allow us to further
configure how recipients currently receive documents.
2024-11-08 13:32:13 +09:00
451723a8ab chore: extract translations 2024-11-08 00:34:25 +09:00
9b769e7e33 fix: email translations (#1454) 2024-11-08 00:33:48 +09:00
61ea4971ad fix: custom team email subject (#1450)
Fixed issue where custom email subjects for teams were being ignored.
2024-11-06 22:16:31 +09:00
ffc61af904 chore: extract translations 2024-11-06 22:03:51 +09:00
efbe94aea8 feat: add signing link copy (#1449) 2024-11-06 21:34:06 +09:00
1b10c55758 fix: update docker environment (#1438) 2024-11-06 19:21:52 +09:00
3da4603a47 fix: content pages breaking during ssr on vercel 2024-11-06 21:02:35 +11:00
dcc2ac8a71 feat(email): support configurable SMTP service (#1447) 2024-11-06 19:00:59 +09:00
5158584955 fix: checkout loading button (#1445) 2024-11-05 20:10:10 +09:00
54c0c6be14 fix: open page 2024-11-05 19:36:36 +09:00
927a24249c chore: add translations (#1444) 2024-11-05 18:53:33 +09:00
a50c758b07 chore: extract translations 2024-11-05 18:26:09 +09:00
cc249357b3 feat: add certificate translations (#1440)
Add translations for audit logs and certificates.
2024-11-05 18:25:23 +09:00
011dabcc04 chore: extract translations 2024-11-05 17:37:05 +09:00
4fa6dc1e21 feat: add template page (#1395)
Add a template page view to allow users to see more details about a
template at a glance.
2024-11-05 17:36:30 +09:00
32b65c4d49 fix: blog posts breaking during ssr on vercel 2024-11-05 18:34:27 +11:00
de880aa821 v1.7.2-rc.4 2024-11-05 13:50:01 +11:00
dc5723c386 chore: add i18n lang to document deleted email 2024-11-05 13:44:00 +11:00
c57d1dc55d chore: add translations (#1443)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-11-05 13:07:55 +11:00
4dd95016b1 feat: i18n for emails (#1442)
## Description

Support setting a document language that will control the language used
for sending emails to recipients. Additional work has been done to
convert all emails to using our i18n implementation so we can later add
controls for sending other kinds of emails in a users target language.

## Related Issue

N/A

## Changes Made

- Added `<Trans>` and `msg` macros to emails
- Introduced a new `renderEmailWithI18N` utility in the lib package
- Updated all emails to use the `<Tailwind>` component at the top level
due to rendering constraints
- Updated the `i18n.server.tsx` file to not use a top level await

## Testing Performed

- Configured document language and verified emails were sent in the
expected language
- Created a document from a template and verified that the templates
language was transferred to the document
2024-11-05 11:52:54 +11:00
04b1ce1aab fix: missing not found page for deleted documents (#1424) 2024-11-04 22:09:52 +09:00
885349ad94 fix: missing signing order when using templates (#1425) 2024-11-03 20:17:41 +09:00
28514ba2e7 fix: duplicate templates (#1434) 2024-11-01 21:29:38 +11:00
8aa6d8e602 chore: add translations (#1433) 2024-11-01 13:22:51 +09:00
378e515843 chore: extract translations 2024-11-01 12:56:07 +09:00
f42e600e3f chore: update workflow 2024-11-01 12:37:54 +09:00
88eaec91c9 chore: extract translations 2024-11-01 11:27:09 +09:00
f199183c78 feat: improve translation coverage (#1427)
Improves translation coverage across the app.
2024-11-01 10:57:32 +11:00
0cee07aed3 v1.7.2-rc.3 2024-10-31 15:33:03 +11:00
f76f87ff1c fix: use key for expansion on embeds 2024-10-31 15:31:40 +11:00
6020336792 v1.7.2-rc.2 2024-10-30 14:37:50 +11:00
634b30aa54 fix: signature flickering during embed 2024-10-30 14:36:35 +11:00
7fc497a642 fix: translation upload token (#1423) 2024-10-29 19:55:49 +09:00
e30ceeb038 style: update common.po (#1402)
Update translations
2024-10-28 11:26:12 +09:00
872762661a style: Update web.po (#1403)
Update translations
2024-10-28 11:23:08 +09:00
5fcd8610c9 fix: translate extract command (#1394)
Change how the translate extract command is run on build
2024-10-28 11:21:49 +09:00
332 changed files with 23331 additions and 3677 deletions

View File

@ -93,6 +93,8 @@ NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
# REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: Defines the service for nodemailer
NEXT_PRIVATE_SMTP_SERVICE=
# OPTIONAL: The API key to use for Resend.com
NEXT_PRIVATE_RESEND_API_KEY=
# OPTIONAL: The API key to use for MailChannels.

View File

@ -89,22 +89,35 @@ jobs:
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
documenso/documenso:latest \
--amend documenso/documenso-amd64:latest \
--amend documenso/documenso-arm64:latest \
# Check if the version is stable (no rc or beta in the version)
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
docker manifest create \
documenso/documenso:latest \
--amend documenso/documenso-amd64:latest \
--amend documenso/documenso-arm64:latest
docker manifest push documenso/documenso:latest
fi
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
docker manifest create \
documenso/documenso:rc \
--amend documenso/documenso-amd64:rc \
--amend documenso/documenso-arm64:rc
docker manifest push documenso/documenso:rc
fi
docker manifest create \
documenso/documenso:$GIT_SHA \
--amend documenso/documenso-amd64:$GIT_SHA \
--amend documenso/documenso-arm64:$GIT_SHA \
--amend documenso/documenso-arm64:$GIT_SHA
docker manifest create \
documenso/documenso:$APP_VERSION \
--amend documenso/documenso-amd64:$APP_VERSION \
--amend documenso/documenso-arm64:$APP_VERSION \
--amend documenso/documenso-arm64:$APP_VERSION
docker manifest push documenso/documenso:latest
docker manifest push documenso/documenso:$GIT_SHA
docker manifest push documenso/documenso:$APP_VERSION
@ -113,21 +126,34 @@ jobs:
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
docker manifest create \
ghcr.io/documenso/documenso:latest \
--amend ghcr.io/documenso/documenso-amd64:latest \
--amend ghcr.io/documenso/documenso-arm64:latest \
# Check if the version is stable (no rc or beta in the version)
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
docker manifest create \
ghcr.io/documenso/documenso:latest \
--amend ghcr.io/documenso/documenso-amd64:latest \
--amend ghcr.io/documenso/documenso-arm64:latest
docker manifest push ghcr.io/documenso/documenso:latest
fi
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
docker manifest create \
ghcr.io/documenso/documenso:rc \
--amend ghcr.io/documenso/documenso-amd64:rc \
--amend ghcr.io/documenso/documenso-arm64:rc
docker manifest push ghcr.io/documenso/documenso:rc
fi
docker manifest create \
ghcr.io/documenso/documenso:$GIT_SHA \
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA \
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA \
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA
docker manifest create \
ghcr.io/documenso/documenso:$APP_VERSION \
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION \
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION \
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION
docker manifest push ghcr.io/documenso/documenso:latest
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION

View File

@ -21,7 +21,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.GH_PAT }}
- uses: ./.github/actions/node-install

View File

@ -11,6 +11,10 @@ Digitally signing documents requires a signing certificate in `.p12` format. You
Follow the steps below to create a free, self-signed certificate for local development.
<Callout type="warning">
These steps should be run on a UNIX based system, otherwise you may run into an error.
</Callout>
<Steps>
### Generate Private Key
@ -38,11 +42,17 @@ You will be prompted to enter some information, such as the certificate's Common
Combine the private key and the self-signed certificate to create a `.p12` certificate. Use the following command:
```bash
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy
```
<Callout type="warning">
If you get the error "Error: Failed to get private key bags", add the `-legacy` flag to the command `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy`.
When running the application in Docker, you may encounter permission issues when attempting to sign documents using your certificate (.p12) file. This happens because the application runs as a non-root user inside the container and needs read access to the certificate.
To resolve this, you'll need to update the certificate file permissions to allow the container user 1001, which runs NextJS, to read it:
```bash
sudo chown 1001 certificate.p12
```
</Callout>
@ -54,8 +64,8 @@ Note that for local development, the password can be left empty.
### Add Certificate to the Project
Finally, add the certificate to the project. Place the `certificate.p12` file in the `/apps/web/resources` directory. If the directory doesn't exist, create it.
Use the `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` environment variable to point at the certificate you created.
The final file path should be `/apps/web/resources/certificate.p12`.
Details about environment variables associated with certificates can be found [here](/developers/self-hosting/signing-certificate#configure-documenso-to-use-the-certificate).
</Steps>

View File

@ -133,7 +133,7 @@ volumes:
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
```bash
docker-compose --env-file ./.env -d up
docker-compose --env-file ./.env up -d
```
The command will start the PostgreSQL database and the Documenso application containers.

View File

@ -11,6 +11,7 @@
"templates": "Templates",
"direct-links": "Direct Signing Links",
"document-visibility": "Document Visibility",
"teams": "Teams",
"-- Legal Overview": {
"type": "separator",
"title": "Legal Overview"

View File

@ -1,18 +0,0 @@
---
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.

View File

@ -0,0 +1,5 @@
{
"general-settings": "General Settings",
"document-visibility": "Document Visibility",
"sender-details": "Email Sender Details"
}

View File

@ -0,0 +1,45 @@
---
title: Document Visibility
description: Learn how to control the visibility of your team documents.
---
import { Callout } from 'nextra/components';
# Team's Document Visibility
The default document visibility option allows you to control who can view and access the documents uploaded to your team account. 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 team members with the role of _Manager or above_ and _Admin_.
- **Admin only** - The document is only visible to the team's admins.
![A screenshot of the document visibility selector from the team's general settings page](/teams/team-general-settings-document-visibility-select.webp)
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option.
<Callout type="warning">
If the team member uploading the document has a role lower than the default document visibility,
the document visibility will be set to a lower visibility level matching the team member's role.
</Callout>
Here's how it works:
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to "_Everyone_".
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Managers and above_".
- Otherwise, the document's visibility is set to the default document visibility.
You can change the visibility of a document at any time by editing the document and selecting a different visibility option.
![A screenshot of the Documenso's document editor page where you can update the document visibility](/teams/document-visibility-settings.webp)
<Callout type="warning">
Updating the default document visibility in the team's general settings will not affect the
visibility of existing documents. You will need to update the visibility of each document
individually.
</Callout>
## A Note on Document Access
The `document owner` (the user who created the document) always has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the document owner can still view and edit the document.
The `recipient` (the user who receives the document for signature, approval, etc.) also has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the recipient can still view and sign the document.

View File

@ -0,0 +1,15 @@
---
title: General Settings
description: Learn how to manage your team's General settings.
---
# General Settings
You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard.
![A screenshot of team's General settings page](/teams/team-general-settings.webp)
The general settings page allows you to update the following settings:
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).

View File

@ -0,0 +1,14 @@
---
title: Email Sender Details
description: Learn how to update the sender details for your team's email notifications.
---
## Sender Details
If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say:
> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf"
If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say:
> "Example Team" has invited you to sign "document.pdf"

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -0,0 +1,64 @@
---
title: Project Babel
description: We are announcing Project Babel - an initiative to support all languages of the world on Documenso.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-11-08
tags:
- Languages
- Community
- Open Source
---
<figure>
<MdxNextImage
src="/blog/babel.png"
width="800"
height="800"
alt=""
/>
<figcaption className="text-center">The tower of Babel to add some Gravitas to this project.</figcaption>
</figure>
> TLDR; We are opening up translations to the community. Read this to add a language: https://documen.so/babel-fish
## Announcing Project Babel: Powering Documenso with Global Language Support
At Documenso, we believe that open source is more than just a software philosophy—its a way to build solutions that are open to all. Now, were happy to take that mission further with Project Babel, a community-driven initiative designed to bring worldwide language support to the Documenso platform. This project aims to enable Documenso to support as many languages as possible.
## Why Language Support Matters
We already have customers from 36 different countries and are seeing traffic from even more. When it comes to critical tools like signature platforms, having a user interface in your native language can make all the difference. No matter who and where you are, your team deserves tools that are fully accessible and intuitive. Thats why were making it our goal to support every language, and we need your help to make it happen! Were building Documenso as a truly global public commodity.
## The Vision Behind Project Babel
The goal of Project Babel is simple but bold: We want to out-ship and out-customize every other document signing tool worldwide. How? By leveraging the collective power of our global community.
Unlike closed-source software, where localization means waiting for updates from the core team, Project Babel lets anyone contribute a new language, improve an existing translation, or customize the experience to meet local cultural nuances. This flexibility isnt just a bonus—its the baseline for truly global products.
Through Project Babel, you can help make Documenso the most inclusive e-signature tool. Whether by adding a language you speak or fine-tuning existing translations, youre shaping a platform that works for everyone, everywhere.
## How You Can Contribute
Weve created a simple GitHub-based contribution flow to get started. Well improve the flow and user experience as the project progresses. As always, your contributions are highly valued.
Check out the contribution guide here: [https://documen.so/babel-fish](https://documen.so/babel-fish)
## Open Source Makes It Possible
Closed-source solutions cant keep up with the speed or depth of customization that open source offers. While other companies might take months or years to localize their products, Documenso can adapt and grow in real-time, thanks to contributions from our community. Whether its a small regional dialect or a widely spoken language, Project Babel ensures that Documenso evolves to meet the needs of people everywhere.
> More importantly, this initiative empowers users. It allows you to control your software experience, ensuring it reflects your culture, language, and unique needs.
Project Babel is more than a localization effort—its the first step toward democratizing access to highly customized software for everyone, no matter where they are or what language they speak. Were incredibly excited about this initiative, but it can only succeed with your participation. We invite you to join us in making Documenso the most linguistically inclusive platform out there.
Ready to get started? Check out the full tutorial and become part of the Babel community today! Lets build open signing for the world: https://documen.so/babel-fish
If you have any questions or comments, reach out on [Twitter / X](https://twitter.com/eltimuro) (DMs 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

@ -1,11 +1,11 @@
{
"name": "@documenso/marketing",
"version": "1.7.2-rc.1",
"version": "1.8.0-rc.4",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev -p 3001",
"build": "turbo run translate:extract && turbo run translate:compile && next build",
"build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

View File

@ -0,0 +1,23 @@
'use client';
import Image from 'next/image';
import type { DocumentTypes } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
const mdxComponents: MDXComponents = {
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
<Image {...props} alt={props.alt ?? ''} />
),
};
export type ContentPageContentProps = {
document: DocumentTypes;
};
export const ContentPageContent = ({ document }: ContentPageContentProps) => {
const MDXContent = useMDXComponent(document.body.code);
return <MDXContent components={mdxComponents} />;
};

View File

@ -1,12 +1,11 @@
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { ContentPageContent } from './content';
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { content: string } }) => {
@ -19,19 +18,13 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
return { title: document.title };
};
const mdxComponents: MDXComponents = {
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
<Image {...props} alt={props.alt ?? ''} />
),
};
/**
* A generic catch all page for the root level that checks for content layer documents.
*
* Will render the document if it exists, otherwise will return a 404.
*/
export default function ContentPage({ params }: { params: { content: string } }) {
setupI18nSSR();
export default async function ContentPage({ params }: { params: { content: string } }) {
await setupI18nSSR();
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
@ -39,11 +32,9 @@ export default function ContentPage({ params }: { params: { content: string } })
notFound();
}
const MDXContent = useMDXComponent(post.body.code);
return (
<article className="prose dark:prose-invert mx-auto">
<MDXContent components={mdxComponents} />
<ContentPageContent document={post} />
</article>
);
}

View File

@ -0,0 +1,23 @@
'use client';
import Image from 'next/image';
import type { BlogPost } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
const mdxComponents: MDXComponents = {
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
<Image {...props} alt={props.alt ?? ''} />
),
};
export type BlogPostContentProps = {
post: BlogPost;
};
export const BlogPostContent = ({ post }: BlogPostContentProps) => {
const MdxContent = useMDXComponent(post.body.code);
return <MdxContent components={mdxComponents} />;
};

View File

@ -1,16 +1,15 @@
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { allBlogPosts } from 'contentlayer/generated';
import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { CallToAction } from '~/components/(marketing)/call-to-action';
import { BlogPostContent } from './content';
export const dynamic = 'force-dynamic';
export const generateMetadata = ({ params }: { params: { post: string } }) => {
@ -42,14 +41,8 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
};
};
const mdxComponents: MDXComponents = {
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
<Image {...props} alt={props.alt ?? ''} />
),
};
export default function BlogPostPage({ params }: { params: { post: string } }) {
setupI18nSSR();
export default async function BlogPostPage({ params }: { params: { post: string } }) {
await setupI18nSSR();
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
@ -57,8 +50,6 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
notFound();
}
const MDXContent = useMDXComponent(post.body.code);
return (
<div>
<article className="prose dark:prose-invert mx-auto py-8">
@ -87,7 +78,7 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
</div>
</div>
<MDXContent components={mdxComponents} />
<BlogPostContent post={post} />
{post.tags.length > 0 && (
<ul className="not-prose flex list-none flex-row space-x-2 px-0">

View File

@ -9,8 +9,8 @@ export const metadata: Metadata = {
title: 'Blog',
};
export default function BlogPage() {
const { i18n } = setupI18nSSR();
export default async function BlogPage() {
const { i18n } = await setupI18nSSR();
const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date);

View File

@ -131,7 +131,7 @@ const fetchEarlyAdopters = async () => {
};
export default async function OpenPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -26,7 +26,7 @@ const fontCaveat = Caveat({
});
export default async function IndexPage() {
setupI18nSSR();
await setupI18nSSR();
const starCount = await fetch('https://api.github.com/repos/documenso/documenso', {
headers: {

View File

@ -30,8 +30,8 @@ export type PricingPageProps = {
};
};
export default function PricingPage() {
setupI18nSSR();
export default async function PricingPage() {
await setupI18nSSR();
return (
<div className="mt-6 sm:mt-12">

View File

@ -163,6 +163,7 @@ export const SinglePlayerClient = () => {
expired: null,
signedAt: null,
readStatus: 'OPENED',
rejectionReason: null,
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',

View File

@ -14,8 +14,8 @@ export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export default function SingleplayerPage() {
setupI18nSSR();
export default async function SingleplayerPage() {
await setupI18nSSR();
return <SinglePlayerClient />;
}

View File

@ -56,7 +56,7 @@ export function generateMetadata() {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();
const { lang, locales, i18n } = setupI18nSSR();
const { lang, locales, i18n } = await setupI18nSSR();
return (
<html

View File

@ -1,11 +1,11 @@
{
"name": "@documenso/web",
"version": "1.7.2-rc.1",
"version": "1.8.0-rc.4",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "turbo run translate:extract && turbo run translate:compile && next build",
"build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build",
"start": "next start",
"lint": "next lint",
"e2e:prepare": "next build && next start",

View File

@ -24,7 +24,7 @@ type AdminDocumentDetailsPageProps = {
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
const document = await getEntireDocument({ id: Number(params.id) });

View File

@ -4,8 +4,8 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { AdminDocumentResults } from './document-results';
export default function AdminDocumentsPage() {
setupI18nSSR();
export default async function AdminDocumentsPage() {
await setupI18nSSR();
return (
<div>

View File

@ -13,7 +13,7 @@ export type AdminSectionLayoutProps = {
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -12,7 +12,7 @@ import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -30,7 +30,7 @@ import { SignerConversionChart } from './signer-conversion-chart';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -14,7 +14,7 @@ import {
} from '@documenso/ui/primitives/table';
export default async function Subscriptions() {
setupI18nSSR();
await setupI18nSSR();
const subscriptions = await findSubscriptions();

View File

@ -16,7 +16,7 @@ type AdminManageUsersProps = {
};
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
setupI18nSSR();
await setupI18nSSR();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;

View File

@ -33,6 +33,8 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import { ResendDocumentActionItem } from '../_action-items/resend-document';
import { DeleteDocumentDialog } from '../delete-document-dialog';
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
@ -62,6 +64,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isCurrentTeamDocument = team && document.team?.url === team.url;
@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
<Trans>Share</Trans>
</DropdownMenuLabel>
{canManageDocument && (
<DocumentRecipientLinkCopyDialog
recipients={document.Recipient}
trigger={
<DropdownMenuItem
disabled={!isPending || isDeleted}
onSelect={(e) => e.preventDefault()}
>
<Copy className="mr-2 h-4 w-4" />
<Trans>Signing Links</Trans>
</DropdownMenuItem>
}
/>
)}
<ResendDocumentActionItem
document={document}
recipients={nonSignedRecipients}

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -133,6 +133,11 @@ export const DocumentPageViewRecentActivity = ({
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
@ -143,17 +148,11 @@ export const DocumentPageViewRecentActivity = ({
))}
</div>
{/* Todo: Translations. */}
<p
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
formatDocumentAuditLogAction(auditLog, userId).description
}`}
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
>
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{formatDocumentAuditLogAction(auditLog, userId).description}
{formatDocumentAuditLogAction(_, auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">

View File

@ -1,16 +1,30 @@
'use client';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import {
AlertTriangle,
CheckIcon,
Clock,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = {
document: Document & {
@ -24,6 +38,7 @@ export const DocumentPageViewRecipients = ({
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const recipients = document.Recipient;
@ -68,53 +83,89 @@ export const DocumentPageViewRecipients = ({
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Approved</Trans>
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
<Trans>Sent</Trans>
</>
) : (
<div className="flex flex-row items-center">
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Ready</Trans>
<Trans>Approved</Trans>
</>
),
)
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
<Trans>Sent</Trans>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Ready</Trans>
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
<Trans>Viewed</Trans>
</>
))
.exhaustive()}
</Badge>
)}
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
<Trans>Viewed</Trans>
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
trigger={
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</Badge>
}
>
<p className="text-sm">
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
{recipient.rejectionReason}
</p>
</PopoverHover>
)}
{document.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
}}
/>
)}
</div>
</li>
))}
</ul>

View File

@ -26,6 +26,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
@ -73,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (team && !isRecipient) {
if (team && !isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
{document.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={recipients} />
)}
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>

View File

@ -7,10 +7,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -176,8 +178,8 @@ export const EditDocumentForm = ({
stepIndex: 3,
},
subject: {
title: msg`Add Subject`,
description: msg`Add the subject and message you wish to send to signers.`,
title: msg`Distribute Document`,
description: msg`Choose how the document will reach recipients`,
stepIndex: 4,
},
};
@ -201,7 +203,7 @@ export const EditDocumentForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl } = data.meta;
const { timezone, dateFormat, redirectUrl, language } = data.meta;
await setSettingsForDocument({
documentId: document.id,
@ -217,6 +219,7 @@ export const EditDocumentForm = ({
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
},
});
@ -305,7 +308,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.meta;
const { subject, message, distributionMethod, emailSettings } = data.meta;
try {
await sendDocument({
@ -314,16 +317,31 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
distributionMethod,
emailSettings,
},
});
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
router.push(documentRootPath);
router.push(documentRootPath);
return;
}
if (document.status === DocumentStatus.DRAFT) {
toast({
title: _(msg`Links Generated`),
description: _(msg`Signing links have been generated for this document.`),
duration: 5000,
});
} else {
router.push(`${documentRootPath}/${document.id}`);
}
} catch (err) {
console.error(err);

View File

@ -55,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient) {
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)

View File

@ -8,8 +8,8 @@ export type DocumentPageProps = {
};
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
setupI18nSSR();
export default async function DocumentEditPage({ params }: DocumentPageProps) {
await setupI18nSSR();
return <DocumentEditPageView params={params} />;
}

View File

@ -6,8 +6,8 @@ import { ChevronLeft, Loader } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
setupI18nSSR();
export default async function Loading() {
await setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">

View File

@ -58,10 +58,6 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
});
};
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
const results = data ?? {
data: [],
perPage: 10,
@ -103,9 +99,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
),
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
},
{
header: 'IP Address',

View File

@ -139,6 +139,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
className="mr-2"
documentId={document.id}
documentStatus={document.status}
teamId={team?.id}
/>
<DownloadAuditLogButton teamId={team?.id} documentId={document.id} />

View File

@ -14,12 +14,14 @@ export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
documentStatus: DocumentStatus;
teamId?: number;
};
export const DownloadCertificateButton = ({
className,
documentId,
documentStatus,
teamId,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@ -29,7 +31,7 @@ export const DownloadCertificateButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const { url } = await downloadCertificate({ documentId, teamId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

@ -8,8 +8,8 @@ export type DocumentsLogsPageProps = {
};
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
setupI18nSSR();
export default async function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
await setupI18nSSR();
return <DocumentLogsPageView params={params} />;
}

View File

@ -8,8 +8,8 @@ export type DocumentPageProps = {
};
};
export default function DocumentPage({ params }: DocumentPageProps) {
setupI18nSSR();
export default async function DocumentPage({ params }: DocumentPageProps) {
await setupI18nSSR();
return <DocumentPageView params={params} />;
}

View File

@ -5,8 +5,8 @@ import { ChevronLeft } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function DocumentSentPage() {
setupI18nSSR();
export default async function DocumentSentPage() {
await setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">

View File

@ -37,6 +37,8 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
@ -69,7 +71,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<Trans>Share</Trans>
</DropdownMenuLabel>
{canManageDocument && (
<DocumentRecipientLinkCopyDialog
recipients={row.Recipient}
trigger={
<DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}>
<div>
<Copy className="mr-2 h-4 w-4" />
<Trans>Signing Links</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
<DocumentShareButton

View File

@ -78,7 +78,7 @@ export const DocumentsDataTable = ({
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
},
{

View File

@ -87,7 +87,7 @@ export const DeleteDocumentDialog = ({
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === 'delete');
setIsDeleteEnabled(event.target.value === _(msg`delete`));
};
return (

View File

@ -117,10 +117,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
{isLoading ? 'Moving...' : 'Move'}
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -16,7 +16,7 @@ export const metadata: Metadata = {
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -23,7 +23,7 @@ export type AuthenticatedDashboardLayoutProps = {
export default async function AuthenticatedDashboardLayout({
children,
}: AuthenticatedDashboardLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const session = await getServerSession(NEXT_AUTH_OPTIONS);

View File

@ -44,11 +44,11 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
const onSubscribeClick = async (priceId: string) => {
try {
setIsFetchingCheckoutSession(true);
setCheckoutSessionPriceId(priceId);
const url = await createCheckout({ priceId });
@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
variant: 'destructive',
});
} finally {
setIsFetchingCheckoutSession(false);
setCheckoutSessionPriceId(null);
}
};
@ -122,7 +122,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
<Button
className="mt-4"
loading={isFetchingCheckoutSession}
disabled={checkoutSessionPriceId !== null}
loading={checkoutSessionPriceId === price.id}
onClick={() => void onSubscribeClick(price.id)}
>
<Trans>Subscribe</Trans>

View File

@ -12,9 +12,10 @@ import { createBillingPortal } from './create-billing-portal.action';
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
children?: React.ReactNode;
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -63,7 +64,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
<Trans>Manage Subscription</Trans>
{children || <Trans>Manage Subscription</Trans>}
</Button>
);
};

View File

@ -24,7 +24,7 @@ export const metadata: Metadata = {
};
export default async function BillingSettingsPage() {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
let { user } = await getRequiredServerComponentSession();
@ -68,60 +68,74 @@ export default async function BillingSettingsPage() {
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
<Trans>
Your current plan is past due. Please update your payment information.
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
))
.otherwise(() => null)}
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>
</div>
{isMissingOrInactiveOrFreePlan && (
<BillingPortalButton>
<Trans>Manage billing</Trans>
</BillingPortalButton>
)}
</div>
<hr className="my-4" />

View File

@ -11,8 +11,8 @@ export type DashboardSettingsLayoutProps = {
children: React.ReactNode;
};
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
setupI18nSSR();
export default async function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
await setupI18nSSR();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">

View File

@ -17,7 +17,7 @@ export const metadata: Metadata = {
};
export default async function ProfileSettingsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();

View File

@ -5,7 +5,7 @@ import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-p
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -14,8 +14,8 @@ export const metadata: Metadata = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
setupI18nSSR();
export default async function SettingsSecurityActivityPage() {
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -21,7 +21,7 @@ export const metadata: Metadata = {
};
export default async function SecuritySettingsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();

View File

@ -17,7 +17,7 @@ export const metadata: Metadata = {
};
export default async function SettingsManagePasskeysPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');

View File

@ -10,7 +10,7 @@ import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-to
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -151,7 +152,10 @@ export const EditTemplateForm = ({
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: data.meta,
meta: {
...data.meta,
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
// Router refresh is here to clear the router cache for when navigating to /documents.

View File

@ -0,0 +1,14 @@
import React from 'react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import type { TemplateEditPageViewProps } from './template-edit-page-view';
import { TemplateEditPageView } from './template-edit-page-view';
type TemplateEditPageProps = Pick<TemplateEditPageViewProps, 'params'>;
export default async function TemplateEditPage({ params }: TemplateEditPageProps) {
await setupI18nSSR();
return <TemplateEditPageView params={params} />;
}

View File

@ -0,0 +1,96 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type';
import { TemplateDirectLinkBadge } from '../../template-direct-link-badge';
import { TemplateDirectLinkDialogWrapper } from '../template-direct-link-dialog-wrapper';
import { EditTemplateForm } from './edit-template';
export type TemplateEditPageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const TemplateEditPageView = async ({ params, team }: TemplateEditPageViewProps) => {
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
redirect(templateRootPath);
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateWithDetailsById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
href={`${templateRootPath}/${templateId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
</div>
</div>
<EditTemplateForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</div>
);
};

View File

@ -1,14 +1,15 @@
import React from 'react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
export type TemplatePageProps = {
params: {
id: string;
};
};
export default function TemplatePage({ params }: TemplatePageProps) {
setupI18nSSR();
export default async function TemplatePage({ params }: TemplatePageProps) {
await setupI18nSSR();
return <TemplatePageView params={params} />;
}

View File

@ -10,11 +10,13 @@ import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
export type TemplatePageViewProps = {
export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
};
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
export const TemplateDirectLinkDialogWrapper = ({
template,
}: TemplateDirectLinkDialogWrapperProps) => {
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
return (

View File

@ -0,0 +1,281 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { Team } from '@documenso/prisma/client';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { SelectItem } from '@documenso/ui/primitives/select';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { DocumentStatus } from '~/components/formatter/document-status';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DataTableActionButton } from '../../documents/data-table-action-button';
import { DataTableActionDropdown } from '../../documents/data-table-action-dropdown';
import { DataTableTitle } from '../../documents/data-table-title';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
.catch(() => undefined),
status: z
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
search: z.coerce
.string()
.optional()
.catch(() => undefined),
});
type TemplatePageViewDocumentsTableProps = {
templateId: number;
team?: Team;
};
export const TemplatePageViewDocumentsTable = ({
templateId,
team,
}: TemplatePageViewDocumentsTableProps) => {
const { _, i18n } = useLingui();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZTemplateSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.document.findDocuments.useQuery(
{
templateId,
teamId: team?.id,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
search: parsedSearchParams.search,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: () => (
<div className="flex flex-row items-center">
<Trans>Source</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Template</Trans>
</h2>
<p>
<Trans>
This document was created by you or a team member using the template above.
</Trans>
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Direct Link</Trans>
</h2>
<p>
<Trans>This document was created using a direct link.</Trans>
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
</div>
),
},
{
id: 'actions',
header: _(msg`Actions`),
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell className="py-4 pr-4">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};

View File

@ -0,0 +1,66 @@
'use client';
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { Template, User } from '@documenso/prisma/client';
export type TemplatePageViewInformationProps = {
userId: number;
template: Template & {
User: Pick<User, 'id' | 'name' | 'email'>;
};
};
export const TemplatePageViewInformation = ({
template,
userId,
}: TemplatePageViewInformationProps) => {
const isMounted = useIsMounted();
const { _, i18n } = useLingui();
const templateInformation = useMemo(() => {
return [
{
description: msg`Uploaded by`,
value: userId === template.userId ? _(msg`You`) : template.User.name ?? template.User.email,
},
{
description: msg`Created`,
value: i18n.date(template.createdAt, { dateStyle: 'medium' }),
},
{
description: msg`Last modified`,
value: DateTime.fromJSDate(template.updatedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{templateInformation.map((item, i) => (
<li
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};

View File

@ -0,0 +1,163 @@
'use client';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DocumentSource } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TemplatePageViewRecentActivityProps = {
templateId: number;
teamId?: number;
documentRootPath: string;
};
export const TemplatePageViewRecentActivity = ({
templateId,
teamId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
templateId,
teamId,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
perPage: 5,
});
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recent documents</Trans>
</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load documents</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{data && (
<>
<ul role="list" className="space-y-6 p-4">
{data.data.length > 0 && results.totalPages > 1 && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
className="text-foreground/70 hover:text-muted-foreground flex items-center text-xs"
>
<Trans>View more</Trans>
</button>
</li>
)}
{results.data.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent documents</Trans>
</p>
</div>
)}
{results.data.map((document, documentIndex) => (
<li key={document.id} className="relative flex gap-x-4">
<div
className={cn(
documentIndex === results.data.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<Link
href={`${documentRootPath}/${document.id}`}
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
>
{match(document.source)
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
<Trans>
Document created by <span className="font-bold">{document.User.name}</span>
</Trans>
))
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (
<Trans>
Document created using a <span className="font-bold">direct link</span>
</Trans>
))
.exhaustive()}
</Link>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(document.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
<Button
className="mx-4 mb-4"
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
>
<Trans>View all related documents</Trans>
</Button>
</>
)}
</section>
);
};

View File

@ -0,0 +1,69 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { PenIcon, PlusIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Recipient, Template } from '@documenso/prisma/client';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
template: Template & {
Recipient: Recipient[];
};
templateRootPath: string;
};
export const TemplatePageViewRecipients = ({
template,
templateRootPath,
}: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui();
const recipients = template.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recipients</Trans>
</h1>
<Link
href={`${templateRootPath}/${template.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
</li>
))}
</ul>
</section>
);
};

View File

@ -1,22 +1,28 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { DocumentSigningOrder, SigningStatus, type Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from '../data-table-action-dropdown';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
import { EditTemplateForm } from './edit-template';
import { UseTemplateDialog } from '../use-template-dialog';
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
import { TemplatePageViewDocumentsTable } from './template-page-view-documents-table';
import { TemplatePageViewInformation } from './template-page-view-information';
import { TemplatePageViewRecentActivity } from './template-page-view-recent-activity';
import { TemplatePageViewRecipients } from './template-page-view-recipients';
export type TemplatePageViewProps = {
params: {
@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
const documentRootPath = formatDocumentsPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
redirect(templateRootPath);
@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateWithDetailsById({
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template;
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = Field.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
name: '',
email: '',
signingStatus: SigningStatus.NOT_SIGNED,
};
return {
...field,
Recipient: recipient,
Signature: null,
};
});
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Templates</Trans>
</Link>
const mockedDocumentMeta = templateMeta
? {
typedSignatureEnabled: false,
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
documentId: 0,
}
: undefined;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Templates</Trans>
</Link>
<div className="flex flex-row justify-between truncate">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<Button className="w-full" asChild>
<Link href={`${templateRootPath}/${template.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
</div>
</div>
<EditTemplateForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
document={template}
key={template.id}
documentData={templateDocumentData}
/>
</CardContent>
</Card>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
documentMeta={mockedDocumentMeta}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
<Trans>Template</Trans>
</h3>
<div>
<DataTableActionDropdown
row={template}
teamId={team?.id}
templateRootPath={templateRootPath}
/>
</div>
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
<Trans>Manage and view template</Trans>
</p>
<div className="mt-4 border-t px-4 pt-4">
<UseTemplateDialog
templateId={template.id}
templateSigningOrder={template.templateMeta?.signingOrder}
recipients={template.Recipient}
documentRootPath={documentRootPath}
trigger={
<Button className="w-full">
<Trans>Use</Trans>
</Button>
}
/>
</div>
</section>
{/* Template information section. */}
<TemplatePageViewInformation template={template} userId={user.id} />
{/* Recipients section. */}
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
{/* Recent activity section. */}
<TemplatePageViewRecentActivity
documentRootPath={documentRootPath}
templateId={template.id}
teamId={team?.id}
/>
</div>
</div>
</div>
<div className="mt-16" id="documents">
<h1 className="mb-4 text-2xl font-bold">
<Trans>Documents created from template</Trans>
</h1>
<TemplatePageViewDocumentsTable team={team} templateId={template.id} />
</div>
</div>
);
};

View File

@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog';
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = {
row: FindTemplateRow;
row: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[];
};
templateRootPath: string;
teamId?: number;
};
@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<Link href={`${templateRootPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>

View File

@ -124,7 +124,7 @@ export const TemplatesDataTable = ({
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type="PRIVATE" />
<TemplateType type={row.original.type} />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
<UseTemplateDialog
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>

View File

@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
setShowNewTemplateDialog(false);
router.push(`${templateRootPath}/${id}`);
router.push(`${templateRootPath}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),

View File

@ -15,8 +15,8 @@ export const metadata: Metadata = {
title: 'Templates',
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
setupI18nSSR();
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
await setupI18nSSR();
return <TemplatesPageView searchParams={searchParams} />;
}

View File

@ -1,3 +1,5 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
@ -15,7 +17,7 @@ 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 { DocumentDistributionMethod, 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';
@ -47,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
distributeDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
@ -91,14 +93,18 @@ export type UseTemplateDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
};
export function UseTemplateDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
trigger,
}: UseTemplateDialogProps) {
const router = useRouter();
@ -112,7 +118,7 @@ export function UseTemplateDialog({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
sendDocument: false,
distributeDocument: false,
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@ -143,7 +149,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
distributeDocument: data.distributeDocument,
});
toast({
@ -152,7 +158,16 @@ export function UseTemplateDialog({
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
let documentPath = `${documentRootPath}/${id}`;
if (
data.distributeDocument &&
documentDistributionMethod === DocumentDistributionMethod.NONE
) {
documentPath += '?action=view-signing-links';
}
router.push(documentPath);
} catch (err) {
const error = AppError.parseError(err);
@ -186,10 +201,12 @@ export function UseTemplateDialog({
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Use Template</Trans>
</Button>
{trigger || (
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Use Template</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@ -289,43 +306,76 @@ export function UseTemplateDialog({
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
name="distributeDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
id="distributeDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
{' '}
The document will be immediately sent to recipients if this is
checked.
</Trans>
</p>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this is
checked.
</Trans>
</p>
<p>
<Trans>Otherwise, the document will be created as a draft.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
<p>
<Trans>
Otherwise, the document will be created as a draft.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
</p>
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to
the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
</div>
</FormItem>
)}
@ -341,10 +391,12 @@ export function UseTemplateDialog({
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? (
{!form.getValues('distributeDocument') ? (
<Trans>Create as draft</Trans>
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Create and send</Trans>
) : (
<Trans>Create as draft</Trans>
<Trans>Create signing links</Trans>
)}
</Button>
</DialogFooter>

View File

@ -1,5 +1,5 @@
'use client';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
@ -25,7 +25,12 @@ const dateFormat: DateTimeFormatOptions = {
hourCycle: 'h12',
};
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*/
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
const { _ } = useLingui();
const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
@ -36,11 +41,11 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Browser</TableHead>
<TableHead>{_(msg`Time`)}</TableHead>
<TableHead>{_(msg`User`)}</TableHead>
<TableHead>{_(msg`Action`)}</TableHead>
<TableHead>{_(msg`IP Address`)}</TableHead>
<TableHead>{_(msg`Browser`)}</TableHead>
</TableRow>
</TableHeader>
@ -74,7 +79,7 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
</TableCell>
<TableCell>{log.ipAddress}</TableCell>

View File

@ -2,13 +2,18 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DOCUMENT_STATUS } from '@documenso/lib/constants/document';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Logo } from '~/components/branding/logo';
@ -21,7 +26,17 @@ type AuditLogProps = {
};
};
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*
* Cannot use dynamicActivate by itself to translate this specific page and all
* children components because `not-found.tsx` page runs and overrides the i18n.
*/
export default async function AuditLog({ searchParams }: AuditLogProps) {
const { i18n } = await setupI18nSSR();
const { _ } = useLingui();
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
@ -44,6 +59,10 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
return redirect('/');
}
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
await dynamicActivate(i18n, documentLanguage);
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
@ -53,31 +72,35 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Version History</h1>
<h1 className="my-8 text-2xl font-bold">{_(msg`Version History`)}</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">Document ID</span>
<span className="font-medium">{_(msg`Document ID`)}</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">Enclosed Document</span>
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">Status</span>
<span className="font-medium">{_(msg`Status`)}</span>
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
<span className="mt-1 block">
{_(
document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description,
).toUpperCase()}
</span>
</p>
<p>
<span className="font-medium">Owner</span>
<span className="font-medium">{_(msg`Owner`)}</span>
<span className="mt-1 block break-words">
{document.User.name} ({document.User.email})
@ -85,7 +108,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
</p>
<p>
<span className="font-medium">Created At</span>
<span className="font-medium">{_(msg`Created At`)}</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.createdAt)
@ -95,7 +118,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
</p>
<p>
<span className="font-medium">Last Updated</span>
<span className="font-medium">{_(msg`Last Updated`)}</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.updatedAt)
@ -105,7 +128,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
</p>
<p>
<span className="font-medium">Time Zone</span>
<span className="font-medium">{_(msg`Time Zone`)}</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
@ -113,13 +136,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
</p>
<div>
<p className="font-medium">Recipients</p>
<p className="font-medium">{_(msg`Recipients`)}</p>
<ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => (
<li key={recipient.id}>
<span className="text-muted-foreground">
[{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}]
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>

View File

@ -2,20 +2,24 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLE_SIGNING_REASONS_ENG,
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
@ -36,11 +40,21 @@ type SigningCertificateProps = {
};
const FRIENDLY_SIGNING_REASONS = {
['__OWNER__']: `I am the owner of this document`,
...RECIPIENT_ROLE_SIGNING_REASONS_ENG,
['__OWNER__']: msg`I am the owner of this document`,
...RECIPIENT_ROLE_SIGNING_REASONS,
};
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*
* Cannot use dynamicActivate by itself to translate this specific page and all
* children components because `not-found.tsx` page runs and overrides the i18n.
*/
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
const { i18n } = await setupI18nSSR();
const { _ } = useLingui();
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
@ -63,6 +77,10 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
return redirect('/');
}
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
await dynamicActivate(i18n, documentLanguage);
const auditLogs = await getDocumentCertificateAuditLogs({
id: documentId,
});
@ -98,17 +116,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
});
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
.with('ACCOUNT', () => 'Account Re-Authentication')
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
.with('PASSKEY', () => 'Passkey Re-Authentication')
.with('EXPLICIT_NONE', () => 'Email')
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
.with(null, () => null)
.exhaustive();
if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
.with('ACCOUNT', () => 'Account Authentication')
.with(null, () => 'Email')
.with('ACCOUNT', () => _(msg`Account Authentication`))
.with(null, () => _(msg`Email`))
.exhaustive();
}
@ -147,7 +165,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
<h1 className="my-8 text-2xl font-bold">{_(msg`Signing Certificate`)}</h1>
</div>
<Card>
@ -155,9 +173,9 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Signer Events</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Details</TableHead>
<TableHead>{_(msg`Signer Events`)}</TableHead>
<TableHead>{_(msg`Signature`)}</TableHead>
<TableHead>{_(msg`Details`)}</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
@ -173,11 +191,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Authentication Level:</span>{' '}
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
@ -199,21 +217,21 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Signature ID:</span>{' '}
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">IP Address:</span>{' '}
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">Device:</span>{' '}
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
@ -227,44 +245,46 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Sent:</span>{' '}
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: 'Unknown'}
: _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Viewed:</span>{' '}
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: 'Unknown'}
: _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Signed:</span>{' '}
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: 'Unknown'}
: _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Reason:</span>{' '}
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block">
{isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role]}
{_(
isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span>
</p>
</div>
@ -280,7 +300,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
Signing certificate provided by:
{_(msg`Signing certificate provided by`)}:
</p>
<Logo className="max-h-6 print:max-h-4" />

View File

@ -14,7 +14,7 @@ type PublicProfileLayoutProps = {
};
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -42,7 +42,7 @@ const BADGE_DATA = {
};
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
setupI18nSSR();
await setupI18nSSR();
const { url: profileUrl } = params;

View File

@ -1,7 +1,7 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@ -77,7 +77,7 @@ export const ConfigureDirectTemplateFormPartial = ({
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email cannot already exist in the template',
message: _(msg`Email cannot already exist in the template`),
path: ['email'],
});
}

View File

@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({
const [step, setStep] = useState<DirectTemplateStep>('configure');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION_ENG[directTemplateRecipient.role];
const recipientActionVerb = _(
RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb,
);
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
configure: {
@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({
stepIndex: 1,
},
sign: {
// Todo: Translations
title: msg`${recipientRoleDescription.actionVerb} document`,
description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`,
title: msg`${recipientActionVerb} document`,
description: msg`${recipientActionVerb} the document to complete the process.`,
stepIndex: 2,
},
};

View File

@ -24,7 +24,7 @@ export type TemplatesDirectPageProps = {
};
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { token } = params;

View File

@ -19,7 +19,7 @@ type RecipientLayoutProps = {
* Such as direct template access, or signing.
*/
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -8,8 +8,8 @@ export type SigningLayoutProps = {
children: React.ReactNode;
};
export default function SigningLayout({ children }: SigningLayoutProps) {
setupI18nSSR();
export default async function SigningLayout({ children }: SigningLayoutProps) {
await setupI18nSSR();
return (
<div>

View File

@ -40,7 +40,7 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({
params: { token },
}: CompletedSigningPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
@ -222,7 +222,7 @@ export default async function CompletedSigningPage({
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600">
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-2">
<Trans>Go Back Home</Trans>
</Link>
)}

View File

@ -124,9 +124,9 @@ export const SigningForm = ({
>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && 'View Document'}
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
@ -166,7 +166,7 @@ export const SigningForm = ({
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
<Trans>Please review the document before signing.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
@ -174,7 +174,9 @@ export const SigningForm = ({
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
@ -186,7 +188,9 @@ export const SigningForm = ({
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
@ -213,7 +217,7 @@ export const SigningForm = ({
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
<Trans>Cancel</Trans>
</Button>
<SignDialog

View File

@ -4,6 +4,8 @@ import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -37,6 +39,7 @@ export const InitialsField = ({
}: InitialsFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const { fullName } = useRequiredSigningContext();
const initials = extractInitials(fullName);
@ -83,8 +86,8 @@ export const InitialsField = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -109,8 +112,8 @@ export const InitialsField = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -126,7 +129,7 @@ export const InitialsField = ({
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Initials
<Trans>Initials</Trans>
</p>
)}

View File

@ -13,7 +13,7 @@ export type SigningLayoutProps = {
};
export default async function SigningLayout({ children }: SigningLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -31,7 +31,7 @@ export type SigningPageProps = {
};
export default async function SigningPage({ params: { token } }: SigningPageProps) {
setupI18nSSR();
await setupI18nSSR();
if (!token) {
return notFound();
@ -43,12 +43,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
@ -69,6 +63,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
@ -99,6 +99,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const { documentMeta } = document;
if (recipient.signingStatus === SigningStatus.REJECTED) {
return redirect(`/sign/${token}/rejected`);
}
if (
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED

View File

@ -0,0 +1,170 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Document } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({
reason: z
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
});
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface RejectDocumentDialogProps {
document: Pick<Document, 'id'>;
token: string;
}
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: rejectDocumentWithToken } =
trpc.recipient.rejectDocumentWithToken.useMutation();
const form = useForm<TRejectDocumentFormSchema>({
resolver: zodResolver(ZRejectDocumentFormSchema),
defaultValues: {
reason: '',
},
});
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
try {
// TODO: Add trpc mutation here
await rejectDocumentWithToken({
documentId: document.id,
token,
reason,
});
toast({
title: 'Document rejected',
description: 'The document has been successfully rejected.',
duration: 5000,
});
setIsOpen(false);
router.push(`/sign/${token}/rejected`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while rejecting the document. Please try again.',
variant: 'destructive',
duration: 5000,
});
}
};
useEffect(() => {
if (searchParams?.get('reject') === 'true') {
setIsOpen(true);
}
}, []);
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Reject Document</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reject Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to reject this document? This action cannot be undone.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onRejectDocument)} className="space-y-4">
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
rows={4}
placeholder="Please provide a reason for rejecting this document"
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
>
<Trans>Reject Document</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,110 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { XCircle } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { FieldType } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
export type RejectedSigningPageProps = {
params: {
token?: string;
};
};
export default async function RejectedSigningPage({ params: { token } }: RejectedSigningPageProps) {
await setupI18nSSR();
if (!token) {
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link href={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}

View File

@ -31,6 +31,7 @@ import { InitialsField } from './initials-field';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
import { RejectDocumentDialog } from './reject-document-dialog';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
@ -57,28 +58,32 @@ export const SigningPageView = ({
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p
className="text-muted-foreground truncate"
title={document.User.name ? document.User.name : ''}
>
{document.User.name}
</p>
</div>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div>
<p
className="text-muted-foreground truncate"
title={document.User.name ? document.User.name : ''}
>
{document.User.name}
</p>
<p className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>({document.User.email}) has invited you to view this document</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>({document.User.email}) has invited you to sign this document</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>({document.User.email}) has invited you to approve this document</Trans>
))
.otherwise(() => null)}
</p>
<p className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>({document.User.email}) has invited you to view this document</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>({document.User.email}) has invited you to sign this document</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>({document.User.email}) has invited you to approve this document</Trans>
))
.otherwise(() => null)}
</p>
</div>
<RejectDocumentDialog document={document} token={recipient.token} />
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card

View File

@ -21,7 +21,7 @@ type WaitingForTurnToSignPageProps = {
export default async function WaitingForTurnToSignPage({
params: { token },
}: WaitingForTurnToSignPageProps) {
setupI18nSSR();
await setupI18nSSR();
if (!token) {
return notFound();

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