Compare commits

..

1 Commits

Author SHA1 Message Date
1aeb6325b0 wip: wip 2024-10-16 15:45:46 +11:00
455 changed files with 11821 additions and 35072 deletions

View File

@ -93,8 +93,6 @@ 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,35 +89,22 @@ 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)"
# 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:latest \
--amend documenso/documenso-amd64:latest \
--amend documenso/documenso-arm64:latest \
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
@ -126,34 +113,21 @@ 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)"
# 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:latest \
--amend ghcr.io/documenso/documenso-amd64:latest \
--amend ghcr.io/documenso/documenso-arm64:latest \
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

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

View File

@ -21,12 +21,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAT }}
ref: ${{ github.event.pull_request.head.ref }}
- uses: ./.github/actions/node-install
- name: Extract translations
run: npm run translate:extract
- name: Extract and compile translations
run: |
npm run translate:extract
npm run translate:compile
- name: Check and commit any files created
run: |

View File

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

View File

@ -27,6 +27,9 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

View File

@ -11,10 +11,6 @@ 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
@ -42,17 +38,11 @@ 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 -legacy
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt
```
<Callout type="warning">
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
```
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`.
</Callout>
@ -64,8 +54,8 @@ Note that for local development, the password can be left empty.
### Add Certificate to the Project
Use the `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` environment variable to point at the certificate you created.
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.
Details about environment variables associated with certificates can be found [here](/developers/self-hosting/signing-certificate#configure-documenso-to-use-the-certificate).
The final file path should be `/apps/web/resources/certificate.p12`.
</Steps>

View File

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

View File

@ -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 up -d
docker-compose --env-file ./.env -d up
```
The command will start the PostgreSQL database and the Documenso application containers.

View File

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

View File

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

View File

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

View File

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

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

@ -1,14 +0,0 @@
---
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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

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

View File

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

@ -8,59 +8,6 @@ Check out what's new in the latest version and read our thoughts on it. For more
---
# Documenso v1.8.0: Team Preferences, Signature Rejection, and Document Distribution
We're excited to announce the release of Documenso v1.8.0! This update brings powerful new features to enhance your document signing process. Here's what's new:
## 🌟 Key New Features
### 1. Team Preferences
Introducing **Team Preferences**, allowing administrators to configure settings and preferences that apply to documents across the entire team. This feature ensures consistency and simplifies management by letting you set default options, permissions, and preferences that automatically apply to all team members.
![Team Preferences](/changelog/v1_8_0/team-global-settings.jpeg)
### 2. Signature Rejection
Recipients now have the option to **reject signatures**. This feature enhances communication by allowing recipients to decline signing, providing feedback or requesting changes before the document is finalized.
<video
src="/changelog/v1_8_0/reject-document.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
### 3. Document Distribution Settings
With the new **Document Distribution Settings**, you have greater control over how your documents are shared. Distribute communications via our automated emails and templates or take full control using our API and your own notifications infrastructure.
## 🔧 Other Improvements
- **Support for Gmail SMTP Service**: Adds support for using Gmail as your SMTP service provider.
- **Certificate and Email Translations**: Added support for multiple languages in document certificates and emails, enhancing the experience for international users.
- **Field Movement Fixes**: Resolved issues related to moving fields within documents, improving the document preparation experience.
- **Docker Environment Update**: Improved Docker setup for smoother deployments and better environment consistency.
- **Billing Access Improvements**: Users now have uninterrupted access to billing information, simplifying account management.
- **Support Time Windows for 2FA Tokens**: Enhanced two-factor authentication by supporting time windows in 2FA tokens, improving flexibility.
## 💡 Recent Features
Don't forget to take advantage of these powerful features from our recent releases:
- **Signing Order**: Define the sequence in which recipients sign your documents for a structured signing process.
- **Document Visibility Controls**: Manage who can view your documents and at what stages, offering greater privacy and control.
- **Embedded Signing Experience**: Integrate the signing process directly into your own applications for a seamless user experience.
**👏 Thank You**
As always, we're grateful for the community's contributions and feedback. Your support helps us improve Documenso and deliver a top-notch open-source document signing solution.
We hope you enjoy the new features in Documenso v1.8.0. Happy signing!
---
# Documenso v1.7.1: Signing order and document visibility
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:

View File

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

View File

@ -2,8 +2,8 @@ declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PUBLIC_MARKETING_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

View File

@ -1,23 +0,0 @@
'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,11 +1,12 @@
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 } }) => {
@ -18,13 +19,19 @@ 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 async function ContentPage({ params }: { params: { content: string } }) {
await setupI18nSSR();
export default function ContentPage({ params }: { params: { content: string } }) {
setupI18nSSR();
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
@ -32,9 +39,11 @@ export default async function ContentPage({ params }: { params: { content: strin
notFound();
}
const MDXContent = useMDXComponent(post.body.code);
return (
<article className="prose dark:prose-invert mx-auto">
<ContentPageContent document={post} />
<MDXContent components={mdxComponents} />
</article>
);
}

View File

@ -1,23 +0,0 @@
'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,15 +1,16 @@
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 } }) => {
@ -41,8 +42,14 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
};
};
export default async function BlogPostPage({ params }: { params: { post: string } }) {
await setupI18nSSR();
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();
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
@ -50,6 +57,8 @@ export default async 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">
@ -78,7 +87,7 @@ export default async function BlogPostPage({ params }: { params: { post: string
</div>
</div>
<BlogPostContent post={post} />
<MDXContent components={mdxComponents} />
{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 async function BlogPage() {
const { i18n } = await setupI18nSSR();
export default function BlogPage() {
const { i18n } = 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() {
await setupI18nSSR();
setupI18nSSR();
const { _ } = useLingui();

View File

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

View File

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

View File

@ -163,7 +163,6 @@ 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 async function SingleplayerPage() {
await setupI18nSSR();
export default function SingleplayerPage() {
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 } = await setupI18nSSR();
const { lang, locales, i18n } = setupI18nSSR();
return (
<html

View File

@ -1,11 +1,11 @@
{
"name": "@documenso/web",
"version": "1.8.1-rc.1",
"version": "1.7.2-rc.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build",
"build": "next build",
"start": "next start",
"lint": "next lint",
"e2e:prepare": "next build && next start",
@ -28,7 +28,6 @@
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"colord": "^2.9.3",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
@ -54,7 +53,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^2.17.3",
"remeda": "^2.12.1",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",

View File

@ -2,7 +2,7 @@ declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PUBLIC_MARKETING_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string;
NEXT_PRIVATE_DATABASE_URL: string;

View File

@ -24,7 +24,7 @@ type AdminDocumentDetailsPageProps = {
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const { i18n } = await setupI18nSSR();
const { i18n } = 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 async function AdminDocumentsPage() {
await setupI18nSSR();
export default function AdminDocumentsPage() {
setupI18nSSR();
return (
<div>

View File

@ -13,7 +13,7 @@ export type AdminSectionLayoutProps = {
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
await setupI18nSSR();
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() {
await setupI18nSSR();
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() {
await setupI18nSSR();
setupI18nSSR();
const { _ } = useLingui();

View File

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

View File

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

View File

@ -33,8 +33,6 @@ 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';
@ -64,7 +62,6 @@ 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;
@ -148,21 +145,6 @@ 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

@ -26,7 +26,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui();
const documentInformation = useMemo(() => {
const info = [
return [
{
description: msg`Uploaded by`,
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
@ -44,20 +44,8 @@ export const DocumentPageViewInformation = ({
.toRelative(),
},
];
if (document.deletedAt) {
info.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return info;
}, [isMounted, document, i18n.locales?.[0] || i18n.locale, userId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -133,11 +133,6 @@ 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" />
@ -148,11 +143,17 @@ 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).description}
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
formatDocumentAuditLogAction(auditLog, userId).description
}`}
>
{formatDocumentAuditLogAction(_, auditLog, userId).description}
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{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,30 +1,16 @@
'use client';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
AlertTriangle,
CheckIcon,
Clock,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
} from 'lucide-react';
import { 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 & {
@ -38,7 +24,6 @@ export const DocumentPageViewRecipients = ({
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const recipients = document.Recipient;
@ -83,89 +68,53 @@ export const DocumentPageViewRecipients = ({
}
/>
<div className="flex flex-row items-center">
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
{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>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
<Trans>Approved</Trans>
<Trans>Ready</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.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>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
</li>
))}
</ul>

View File

@ -26,7 +26,6 @@ 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,
@ -74,7 +73,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (team && !isRecipient && document?.userId !== user.id) {
if (team && !isRecipient) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
@ -135,10 +134,6 @@ 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>
@ -146,10 +141,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="flex flex-row justify-between truncate">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
@ -221,7 +213,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm">
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>

View File

@ -7,12 +7,10 @@ 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';
@ -114,24 +112,6 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: updateTypedSignature } =
trpc.document.updateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({
...(oldData || initialDocument),
...newData,
id: Number(newData.id),
}),
);
},
});
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newRecipients) => {
@ -178,8 +158,8 @@ export const EditDocumentForm = ({
stepIndex: 3,
},
subject: {
title: msg`Distribute Document`,
description: msg`Choose how the document will reach recipients`,
title: msg`Add Subject`,
description: msg`Add the subject and message you wish to send to signers.`,
stepIndex: 4,
},
};
@ -203,7 +183,7 @@ export const EditDocumentForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language } = data.meta;
const { timezone, dateFormat, redirectUrl } = data.meta;
await setSettingsForDocument({
documentId: document.id,
@ -219,7 +199,6 @@ export const EditDocumentForm = ({
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
},
});
@ -279,11 +258,6 @@ export const EditDocumentForm = ({
fields: data.fields,
});
await updateTypedSignature({
documentId: document.id,
typedSignatureEnabled: data.typedSignatureEnabled,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@ -308,7 +282,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailSettings } = data.meta;
const { subject, message } = data.meta;
try {
await sendDocument({
@ -317,31 +291,16 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
distributionMethod,
emailSettings,
},
});
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
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}`);
}
router.push(documentRootPath);
} catch (err) {
console.error(err);
@ -428,7 +387,6 @@ export const EditDocumentForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
teamId={team?.id}
/>

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 && document?.userId !== user.id) {
if (!isRecipient) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
@ -109,10 +109,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<Trans>Documents</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>

View File

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

View File

@ -121,10 +121,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
<div className="flex flex-col justify-between truncate sm:flex-row">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
@ -142,7 +139,6 @@ 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,14 +14,12 @@ export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
documentStatus: DocumentStatus;
teamId?: number;
};
export const DownloadCertificateButton = ({
className,
documentId,
documentStatus,
teamId,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@ -31,7 +29,7 @@ export const DownloadCertificateButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId, teamId });
const { url } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

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

View File

@ -8,8 +8,8 @@ export type DocumentPageProps = {
};
};
export default async function DocumentPage({ params }: DocumentPageProps) {
await setupI18nSSR();
export default function DocumentPage({ params }: DocumentPageProps) {
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 async function DocumentSentPage() {
await setupI18nSSR();
export default function DocumentSentPage() {
setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">

View File

@ -7,7 +7,6 @@ import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
ArchiveRestore,
CheckCircle,
Copy,
Download,
@ -24,8 +23,8 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -38,13 +37,10 @@ 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';
import { MoveDocumentDialog } from './move-document-dialog';
import { RestoreDocumentDialog } from './restore-document-dialog';
export type DataTableActionDropdownProps = {
row: Document & {
@ -63,7 +59,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
if (!session) {
return null;
@ -74,12 +69,11 @@ 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;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const isDeletedDocument = row.deletedAt !== null;
const documentsPath = formatDocumentsPath(team?.url);
@ -185,42 +179,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Void
</DropdownMenuItem> */}
{isDeletedDocument ? (
<DropdownMenuItem
onClick={() => setRestoreDialogOpen(true)}
disabled={Boolean(!canManageDocument)}
>
<ArchiveRestore className="mr-2 h-4 w-4" />
Restore
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
<DropdownMenuLabel>
<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
@ -253,16 +223,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
onOpenChange={setMoveDialogOpen}
/>
<RestoreDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={row.id}

View File

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

View File

@ -47,8 +47,6 @@ export const DeleteDocumentDialog = ({
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const deleteMessage = msg`delete`;
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
@ -89,7 +87,7 @@ export const DeleteDocumentDialog = ({
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
setIsDeleteEnabled(event.target.value === 'delete');
};
return (
@ -183,7 +181,7 @@ export const DeleteDocumentDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
placeholder={_(msg`Type 'delete' to confirm`)}
/>
)}

View File

@ -4,10 +4,10 @@ import { Trans } from '@lingui/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats-new';
import { getStats } from '@documenso/lib/server-only/document/get-stats-new';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client';
@ -35,7 +35,7 @@ export interface DocumentsPageViewProps {
senderIds?: string;
search?: string;
};
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
}
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
@ -50,14 +50,25 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const getStatOptions: GetStatsInput = {
user,
period,
team,
search,
};
if (team) {
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
currentTeamMemberRole,
currentUserEmail: user.email,
userId: user.id,
};
}
const stats = await getStats(getStatOptions);
const results = await findDocuments({
@ -117,7 +128,6 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.BIN,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger

View File

@ -1,6 +1,6 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2, Trash } from 'lucide-react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -30,11 +30,6 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird,
}))
.with(ExtendedDocumentStatus.BIN, () => ({
title: msg`No documents in the bin`,
message: msg`There are no documents in the bin.`,
icon: Trash,
}))
.otherwise(() => ({
title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
@ -47,6 +42,7 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">{_(title)}</h3>

View File

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

View File

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

View File

@ -1,90 +0,0 @@
import { useRouter } from 'next/navigation';
import type { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@documenso/ui/primitives/alert-dialog';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
type RestoreDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export function RestoreDocumentDialog({
id,
teamId,
open,
onOpenChange,
documentTitle,
canManageDocument,
}: RestoreDocumentDialogProps) {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: restoreDocument, isLoading } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document restored',
description: `"${documentTitle}" has been successfully restored`,
duration: 5000,
});
onOpenChange(false);
},
});
const onRestore = async () => {
try {
await restoreDocument({ id, teamId });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be restored at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<AlertDialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
You are about to restore the document <strong>"{documentTitle}"</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
type="button"
loading={isLoading}
onClick={onRestore}
disabled={!canManageDocument}
>
Restore
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -23,7 +23,7 @@ export type AuthenticatedDashboardLayoutProps = {
export default async function AuthenticatedDashboardLayout({
children,
}: AuthenticatedDashboardLayoutProps) {
await setupI18nSSR();
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 [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
const onSubscribeClick = async (priceId: string) => {
try {
setCheckoutSessionPriceId(priceId);
setIsFetchingCheckoutSession(true);
const url = await createCheckout({ priceId });
@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
variant: 'destructive',
});
} finally {
setCheckoutSessionPriceId(null);
setIsFetchingCheckoutSession(false);
}
};
@ -122,8 +122,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
<Button
className="mt-4"
disabled={checkoutSessionPriceId !== null}
loading={checkoutSessionPriceId === price.id}
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
<Trans>Subscribe</Trans>

View File

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

View File

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

View File

@ -11,8 +11,8 @@ export type DashboardSettingsLayoutProps = {
children: React.ReactNode;
};
export default async function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
await setupI18nSSR();
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
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() {
await setupI18nSSR();
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() {
await setupI18nSSR();
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

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

View File

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

View File

@ -17,7 +17,7 @@ export const metadata: Metadata = {
};
export default async function SettingsManagePasskeysPage() {
await setupI18nSSR();
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 } = await setupI18nSSR();
const { i18n } = setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -7,7 +7,6 @@ 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,
@ -141,23 +140,6 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: updateTypedSignature } =
trpc.template.updateTemplateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({
...(oldData || initialTemplate),
...newData,
id: Number(newData.id),
}),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
@ -169,10 +151,7 @@ export const EditTemplateForm = ({
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: {
...data.meta,
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
meta: data.meta,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
@ -228,12 +207,6 @@ export const EditTemplateForm = ({
fields: data.fields,
});
await updateTypedSignature({
templateId: template.id,
teamId: team?.id,
typedSignatureEnabled: data.typedSignatureEnabled,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@ -248,13 +221,14 @@ export const EditTemplateForm = ({
duration: 5000,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding fields.`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
@ -323,7 +297,6 @@ export const EditTemplateForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
</Stepper>
</DocumentFlowFormContainer>

View File

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

@ -1,99 +0,0 @@
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 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] 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,15 +1,14 @@
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';
export type TemplatePageProps = {
params: {
id: string;
};
};
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
export default async function TemplatePage({ params }: TemplatePageProps) {
await setupI18nSSR();
export default function TemplatePage({ params }: TemplatePageProps) {
setupI18nSSR();
return <TemplatePageView params={params} />;
}

View File

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

View File

@ -1,281 +0,0 @@
'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

@ -1,66 +0,0 @@
'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

@ -1,163 +0,0 @@
'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

@ -1,69 +0,0 @@
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,28 +1,22 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { ChevronLeft, LucideEdit } from 'lucide-react';
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 { 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 { 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 { 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 { UseTemplateDialog } from '../use-template-dialog';
import { EditTemplateForm } from './edit-template';
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: {
@ -36,7 +30,6 @@ 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);
@ -44,54 +37,30 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
const template = await getTemplateWithDetailsById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
if (!template || !template.templateDocumentData) {
redirect(templateRootPath);
}
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,
};
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const mockedDocumentMeta = templateMeta
? {
...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 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>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
<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>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
@ -108,97 +77,17 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div>
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<div className="mt-2 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>
<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>
<EditTemplateForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</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 { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import {
DropdownMenu,
DropdownMenuContent,
@ -23,10 +23,7 @@ import { MoveTemplateDialog } from './move-template-dialog';
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = {
row: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[];
};
row: FindTemplateRow;
templateRootPath: string;
teamId?: number;
};
@ -60,7 +57,7 @@ export const DataTableActionDropdown = ({
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}/edit`}>
<Link href={`${templateRootPath}/${row.id}`}>
<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={row.original.type} />
<TemplateType type="PRIVATE" />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
@ -144,8 +144,6 @@ export const TemplatesDataTable = ({
<div className="flex items-center gap-x-4">
<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}/edit`);
router.push(`${templateRootPath}/${id}`);
} catch {
toast({
title: _(msg`Something went wrong`),

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